feat(0.2.1): 合并 Windows 外壳 + 离线编辑, 版本 0.2.1, 正式域名 https://iboard.honor3.com
@@ -0,0 +1,10 @@
|
||||
# Windows 外壳构建模式 (npm run build:shell / vite build --mode shell)
|
||||
# 外壳内前端跑在 tauri://localhost, 没有同源后端, API 必须指向远端 Express。
|
||||
# 与 android 打包脚本 (scripts/wsl/build-apk.sh) 注入的值保持一致;
|
||||
# 进程环境变量优先级高于本文件, android 流程不受影响。
|
||||
#
|
||||
# 0.2.1 起正式域名: https://iboard.honor3.com (NPM 反代 -> baihu:3001, 受信证书)
|
||||
# PowerSync 同步端点默认同域 (NPM 把 /sync 反代到同步服务); 若改用独立子域,
|
||||
# 打包时用 VITE_POWERSYNC_URL=https://sync.example.com 覆盖即可。
|
||||
VITE_API_BASE=https://iboard.honor3.com
|
||||
VITE_POWERSYNC_URL=https://iboard.honor3.com
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
env:
|
||||
# 固定工具链版本, 便于 actions/cache 命中
|
||||
# Android 后端地址 (bake 进 APK); 与 scripts/wsl/build-apk.sh 保持一致
|
||||
VITE_API_BASE: http://bh.vps.honor3.com:3001
|
||||
VITE_API_BASE: https://iboard.honor3.com
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
# 关键: 显式固定 Android/OTA 后端地址, 覆盖 runner 环境里可能存在的
|
||||
# 其他 VITE_API_BASE 值。否则 OTA 包里的前端可能连错后端, 导致
|
||||
# "OTA 更新后登录失败/进不去内部"。与 android-build.yml 保持一致。
|
||||
VITE_API_BASE: http://bh.vps.honor3.com:3001
|
||||
VITE_API_BASE: https://iboard.honor3.com
|
||||
# OTA bundle 要求的最低原生二进制版本。
|
||||
# 与 package.json 的应用版本是**两个概念**: 前端 OTA 不碰原生层,
|
||||
# 现有 APK 仍是 0.1.0, 所以这里固定 0.1.0 (否则旧 APK 会被判定为不兼容而收不到更新)。
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>设计稿 · 视图切换移入项目栏 (T-VW-02/03 调整)</title>
|
||||
<style>
|
||||
:root { --primary:#1677ff; --text:#333; --muted:#999; --border:#eee; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||
body { background:#eceef1; padding: 24px; display:flex; gap:28px; flex-wrap:wrap; }
|
||||
.phone { width:375px; background:#f5f5f5; border-radius:18px; overflow:hidden; box-shadow:0 8px 30px rgba(0,0,0,.15); }
|
||||
.phone h3 { font-size:13px; color:#556; padding:10px 14px 0; font-weight:600; }
|
||||
.phone .cap { font-size:11px; color:#889; padding:2px 14px 10px; }
|
||||
.bar { display:flex; align-items:center; gap:8px; padding:10px 12px; background:#fff; border-bottom:1px solid #f0f0f0; }
|
||||
.pname { font-size:15px; font-weight:700; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
|
||||
/* 紧凑分段切换: 高 26px, 内边距收窄, 选中项白底浮起 */
|
||||
.vseg { display:flex; background:#f2f3f5; border-radius:8px; padding:2px; flex-shrink:0; }
|
||||
.vseg div { font-size:12px; line-height:1; padding:5px 11px; border-radius:6px; color:var(--muted); cursor:pointer; white-space:nowrap; }
|
||||
.vseg div.on { background:#fff; color:var(--text); font-weight:600; box-shadow:0 1px 2px rgba(0,0,0,.08); }
|
||||
.mini { font-size:11px; color:var(--primary); background:#f0f5ff; border-radius:6px; padding:5px 8px; white-space:nowrap; flex-shrink:0; cursor:pointer; }
|
||||
.mini.solid { background:var(--primary); color:#fff; }
|
||||
.mini.ghost { background:#fff; border:1px solid #e3e6ea; color:#666; }
|
||||
.bar-note { font-size:10px; color:#b0b6bf; padding:6px 12px; background:#fafbfc; border-bottom:1px dashed #e5e8ec; }
|
||||
.demo-body { padding:12px; }
|
||||
.card { background:#fff; border-radius:9px; padding:10px 12px; margin-bottom:7px; font-size:13px; color:#444; box-shadow:0 1px 2px rgba(0,0,0,.04); display:flex; gap:8px; align-items:center; }
|
||||
.card .dot { width:8px; height:8px; border-radius:50%; background:#c9ced6; flex-shrink:0; }
|
||||
.cols { display:flex; gap:8px; }
|
||||
.col { flex:1; background:#fff; border-radius:9px; padding:8px; }
|
||||
.col b { font-size:11px; color:#556; }
|
||||
.col .ccard { background:#f6f7f9; border-radius:6px; font-size:11px; color:#555; padding:6px; margin-top:6px; }
|
||||
.anno { font-size:11px; color:#7a828e; padding:8px 14px 14px; line-height:1.7; background:#fafbfc; }
|
||||
.anno b { color:#43506b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="phone">
|
||||
<h3>状态 1 · 列表视图(默认)</h3>
|
||||
<div class="cap">切换段控放在项目名右侧, 与名称同一行</div>
|
||||
<div class="bar">
|
||||
<div class="pname">我的看板项目</div>
|
||||
<div class="vseg"><div class="on">列表</div><div>看板</div></div>
|
||||
<div class="mini">已归档</div>
|
||||
<div class="mini ghost">管理</div>
|
||||
<div class="mini solid">+ 新增</div>
|
||||
</div>
|
||||
<div class="bar-note">「项目管理」按钮文案收窄为「管理」, 与「已归档」同为次级样式, 突出右侧主操作</div>
|
||||
<div class="demo-body">
|
||||
<div class="card"><span class="dot"></span>梳理 V0.2 归档需求</div>
|
||||
<div class="card"><span class="dot"></span>OTA 发布说明样式修复</div>
|
||||
<div class="card"><span class="dot"></span>看板列滚动条位置调整</div>
|
||||
</div>
|
||||
<div class="anno">
|
||||
<b>规格</b><br/>
|
||||
· 分段控件高 26px, 选中项白底浮起(复用现有 .seg 体系收窄版)<br/>
|
||||
· 项目名 flex:1 + 单行省略, 最短保留 4 字符<br/>
|
||||
· 段控始终紧贴名称右侧, 距离 8px
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="phone">
|
||||
<h3>状态 2 · 看板视图</h3>
|
||||
<div class="cap">点「看板」后段控选中项滑换, 其余布局不变</div>
|
||||
<div class="bar">
|
||||
<div class="pname">我的看板项目</div>
|
||||
<div class="vseg"><div>列表</div><div class="on">看板</div></div>
|
||||
<div class="mini">已归档</div>
|
||||
<div class="mini ghost">管理</div>
|
||||
<div class="mini solid">+ 新增</div>
|
||||
</div>
|
||||
<div class="demo-body">
|
||||
<div class="cols">
|
||||
<div class="col"><b>Plan · 2</b><div class="ccard">梳理归档需求</div><div class="ccard">OTA 说明修复</div></div>
|
||||
<div class="col"><b>Doing · 1</b><div class="ccard">滚动条调整</div></div>
|
||||
<div class="col"><b>Done · 1</b><div class="ccard">版本方案落地</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="anno">
|
||||
<b>交互</b><br/>
|
||||
· 点按即切换并持久化(设备端记忆, T-VW-02/03 不变)<br/>
|
||||
· 切换不引发项目栏高度变化, 下方视图平滑重排<br/>
|
||||
· 长按/拖动手势不受影响(段控独立控件, 不在卡片上)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="phone">
|
||||
<h3>状态 3 · 长项目名</h3>
|
||||
<div class="cap">名称被压缩时单行省略, 段控与按钮永不换行/收缩</div>
|
||||
<div class="bar">
|
||||
<div class="pname">经济数据整理与策略研究-2026年第一季度重点项目</div>
|
||||
<div class="vseg"><div class="on">列表</div><div>看板</div></div>
|
||||
<div class="mini">已归档</div>
|
||||
<div class="mini ghost">管理</div>
|
||||
<div class="mini solid">+ 新增</div>
|
||||
</div>
|
||||
<div class="demo-body">
|
||||
<div class="card"><span class="dot"></span>完整项目名在详情/管理页仍可见</div>
|
||||
</div>
|
||||
<div class="anno">
|
||||
<b>宽度预算 (375px)</b><br/>
|
||||
· 左右内边距 24 + 段控 90 + 已归档 48 + 管理 42 + 新增 54 + 间距 32 ≈ 290<br/>
|
||||
· 名称可用 ≈ 85px(约 6 个汉字)起, 不足部分省略<br/>
|
||||
· 若仍嫌拥挤, 备选:「已归档」「管理」收进 ⋯ 溢出菜单(二期可选, 本稿未采用)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -3228,6 +3228,7 @@
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
||||
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "iboard",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
@@ -8,6 +8,9 @@
|
||||
"dev:server": "node --watch --experimental-strip-types --no-warnings server/index.js",
|
||||
"dev": "concurrently -n web,api -c blue,green \"npm run dev:web\" \"npm run dev:server\"",
|
||||
"build": "vite build",
|
||||
"build:shell": "vite build --mode shell",
|
||||
"build:win": "node scripts/build-windows.mjs",
|
||||
"build:win:debug": "node scripts/build-windows.mjs --debug",
|
||||
"build:ota": "node scripts/build-ota.mjs",
|
||||
"preview": "vite preview",
|
||||
"start": "node --experimental-strip-types --no-warnings server/index.js",
|
||||
@@ -55,8 +58,8 @@
|
||||
"ws": "^8.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@tauri-apps/api": "^2.12.4",
|
||||
"@tauri-apps/cli": "^2.12.4",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"cheerio": "^1.2.0",
|
||||
"concurrently": "^9.0.0",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<!-- 悬浮球 (W-SNP-02): 半圆形贴边图标, 圆弧朝屏幕内侧, 由 Rust 控制位置与主题 -->
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0; padding: 0; width: 100%; height: 100%;
|
||||
background: transparent; overflow: hidden; user-select: none;
|
||||
}
|
||||
#ball { position: fixed; inset: 0; cursor: pointer; transition: transform .12s ease; }
|
||||
/* 半圆: 直边贴屏幕边缘, 圆弧朝内 (四边方向) */
|
||||
.dock-left #ball { border-radius: 0 28px 28px 0; } /* 左缘 → 凸向右 */
|
||||
.dock-right #ball { border-radius: 28px 0 0 28px; } /* 右缘 → 凸向左 */
|
||||
.dock-top #ball { border-radius: 0 0 28px 28px; } /* 上缘 → 凸向下 */
|
||||
.dock-bottom #ball { border-radius: 28px 28px 0 0; } /* 下缘 → 凸向上 */
|
||||
/* 深浅色 (W-THE-01): 跟随系统应用模式 */
|
||||
.theme-light #ball { background: rgba(28, 28, 30, 0.92); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35); }
|
||||
.theme-dark #ball { background: rgba(245, 245, 245, 0.95); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25); }
|
||||
#ball::after {
|
||||
content: ""; position: absolute; left: 50%; top: 50%;
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.theme-light #ball::after { background: rgba(255, 255, 255, 0.85); }
|
||||
.theme-dark #ball::after { background: rgba(30, 30, 30, 0.75); }
|
||||
#ball:hover { transform: scale(1.06); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="dock-left theme-light">
|
||||
<div id="ball"></div>
|
||||
<script src="./dock.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
// 悬浮球交互 (W-SNP-04/05/12/13/14):
|
||||
// mouseenter/leave → hover 延时恢复 (Rust 侧计时 150ms)
|
||||
// 单击 (位移 ≤4px) → 立即恢复, 触屏点按等效
|
||||
// 拖动 → dock_drag_move 增量移动, 结束时按最近边停靠/换边/拖出
|
||||
const { invoke } = window.__TAURI__.core
|
||||
const ball = document.getElementById('ball')
|
||||
|
||||
let dragging = false
|
||||
let moved = 0
|
||||
let lastX = 0
|
||||
let lastY = 0
|
||||
|
||||
ball.addEventListener('mouseenter', () => invoke('dock_hover_start'))
|
||||
ball.addEventListener('mouseleave', () => invoke('dock_hover_end'))
|
||||
ball.addEventListener('contextmenu', (e) => e.preventDefault())
|
||||
|
||||
ball.addEventListener('pointerdown', (e) => {
|
||||
dragging = true
|
||||
moved = 0
|
||||
lastX = e.clientX
|
||||
lastY = e.clientY
|
||||
ball.setPointerCapture(e.pointerId)
|
||||
invoke('dock_drag_start')
|
||||
})
|
||||
|
||||
ball.addEventListener('pointermove', (e) => {
|
||||
if (!dragging) return
|
||||
const dx = e.clientX - lastX
|
||||
const dy = e.clientY - lastY
|
||||
if (dx === 0 && dy === 0) return
|
||||
lastX = e.clientX
|
||||
lastY = e.clientY
|
||||
moved += Math.abs(dx) + Math.abs(dy)
|
||||
if (moved > 4) {
|
||||
// clientX 为逻辑像素, 增量按 DPI 缩放换算成物理像素 (Rust 注入 __SCALE)
|
||||
const s = window.__SCALE || 1
|
||||
invoke('dock_drag_move', { dx: Math.round(dx * s), dy: Math.round(dy * s) })
|
||||
}
|
||||
})
|
||||
|
||||
ball.addEventListener('pointerup', (e) => {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
try { ball.releasePointerCapture(e.pointerId) } catch { /* 已释放 */ }
|
||||
if (moved <= 4) {
|
||||
invoke('dock_click')
|
||||
} else {
|
||||
invoke('dock_drag_end')
|
||||
}
|
||||
})
|
||||
|
||||
ball.addEventListener('pointercancel', () => {
|
||||
dragging = false
|
||||
invoke('dock_drag_end')
|
||||
})
|
||||
|
||||
// Rust 在停靠/换边/主题变化时调用
|
||||
window.applyDock = (edge, theme) => {
|
||||
document.body.className = 'dock-' + edge + ' theme-' + theme
|
||||
}
|
||||
window.applyTheme = (theme) => {
|
||||
const edge = [...document.body.classList].find((c) => c.startsWith('dock-')) || 'dock-left'
|
||||
document.body.className = edge + ' theme-' + theme
|
||||
}
|
||||
window.__SCALE = 1
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
// Windows 外壳一键构建 (开发用): 安装包 (NSIS) + 便携版 (portable zip)
|
||||
//
|
||||
// 用法:
|
||||
// npm run build:win # 完整流程
|
||||
// node scripts/build-windows.mjs --skip-check # 跳过版本一致性校验
|
||||
// node scripts/build-windows.mjs --debug # debug 构建 (快速验证, 产物带 -debug 后缀)
|
||||
//
|
||||
// 流程:
|
||||
// 1) 版本一致性校验 (scripts/ci/check-version.mjs, 外壳三件套)
|
||||
// 2) 前端构建 (build:shell, 注入 VITE_API_BASE 指向远端后端)
|
||||
// 3) tauri build --bundles nsis → 编译一次 release exe + NSIS 安装包
|
||||
// 4) 复用同一个 exe 组装便携版目录并压成 zip (不二次编译)
|
||||
//
|
||||
// 产物 (artifacts/windows/):
|
||||
// iBoard_<ver>_x64-setup.exe 安装包 (当前用户安装, 免管理员)
|
||||
// iBoard_<ver>_x64-portable.zip 便携版 (解压即用)
|
||||
// portable/ 便携版解压目录 (iBoard.exe + README.txt)
|
||||
//
|
||||
// 说明: 便携版不内置 WebView2 —— 目标机需 Win10 19041+ / Win11 (系统自带 WebView2 Runtime)。
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const args = process.argv.slice(2)
|
||||
const skipCheck = args.includes('--skip-check')
|
||||
const isDebug = args.includes('--debug')
|
||||
const profile = isDebug ? 'debug' : 'release'
|
||||
|
||||
const conf = JSON.parse(fs.readFileSync(path.join(root, 'src-tauri', 'tauri.conf.json'), 'utf8'))
|
||||
const version = String(conf.version || '0.0.0')
|
||||
const product = conf.productName || 'iBoard'
|
||||
const suffix = isDebug ? '-debug' : ''
|
||||
|
||||
const artifactsDir = path.join(root, 'artifacts', 'windows')
|
||||
const targetDir = path.join(root, 'src-tauri', 'target', profile)
|
||||
|
||||
function log(msg) {
|
||||
console.log(`\x1b[36m[build-win]\x1b[0m ${msg}`)
|
||||
}
|
||||
function fail(msg) {
|
||||
console.error(`\x1b[31m[build-win] 失败: ${msg}\x1b[0m`)
|
||||
process.exit(1)
|
||||
}
|
||||
function run(cmd, cmdArgs, opts = {}) {
|
||||
log(`$ ${cmd} ${cmdArgs.join(' ')}`)
|
||||
const res = spawnSync(cmd, cmdArgs, {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
...opts,
|
||||
})
|
||||
if (res.status !== 0) fail(`${cmd} ${cmdArgs.join(' ')} 退出码 ${res.status}`)
|
||||
}
|
||||
|
||||
// ---------- 0. 前置检查 ----------
|
||||
if (process.platform !== 'win32') fail('本脚本仅支持 Windows')
|
||||
if (!fs.existsSync(path.join(root, 'node_modules'))) fail('缺少 node_modules, 请先运行 npm ci')
|
||||
|
||||
log(`项目 ${product} 外壳版本 ${version} (${profile})`)
|
||||
|
||||
// ---------- 1. 版本一致性 ----------
|
||||
if (!skipCheck) {
|
||||
log('校验版本一致性 (外壳 tauri.conf.json / Cargo.toml / Cargo.lock)')
|
||||
run('node', ['scripts/ci/check-version.mjs'], {
|
||||
env: { ...process.env, BRANCH: process.env.BRANCH || '' },
|
||||
})
|
||||
} else {
|
||||
log('跳过版本一致性校验 (--skip-check)')
|
||||
}
|
||||
|
||||
// ---------- 2. 前端构建 ----------
|
||||
log('构建前端 (build:shell, VITE_API_BASE 指向远端后端)')
|
||||
run('npm', ['run', 'build:shell'])
|
||||
|
||||
// ---------- 3. 安装包 (NSIS) ----------
|
||||
const tauriArgs = ['tauri', 'build']
|
||||
if (isDebug) tauriArgs.push('--debug')
|
||||
// 只出 NSIS: 避免 bundle.targets="all" 在 Windows 上拉 WiX 打 MSI
|
||||
tauriArgs.push('--bundles', 'nsis')
|
||||
log('编译外壳并生成 NSIS 安装包')
|
||||
run('npx', tauriArgs)
|
||||
|
||||
// ---------- 4. 便携版 ----------
|
||||
const exePath = path.join(targetDir, `iboard${suffix}.exe`)
|
||||
if (!fs.existsSync(exePath)) fail(`未找到构建产物 ${exePath}`)
|
||||
|
||||
fs.rmSync(artifactsDir, { recursive: true, force: true })
|
||||
fs.mkdirSync(artifactsDir, { recursive: true })
|
||||
|
||||
const portableDir = path.join(artifactsDir, 'portable')
|
||||
fs.mkdirSync(portableDir, { recursive: true })
|
||||
const portableExe = path.join(portableDir, `${product}.exe`)
|
||||
fs.copyFileSync(exePath, portableExe)
|
||||
|
||||
const readme = `${product} ${version} 便携版 (Windows x64)
|
||||
=================================================
|
||||
|
||||
免安装: 双击 ${product}.exe 即可运行。
|
||||
|
||||
系统要求
|
||||
- Windows 10 19041+ 或 Windows 11 (x64)
|
||||
- WebView2 Runtime: Win10/11 系统自带; 若提示缺失, 请安装
|
||||
https://developer.microsoft.com/microsoft-edge/webview2/
|
||||
|
||||
使用说明
|
||||
- 关闭窗口 = 隐藏到托盘, 不会退出; 退出请用托盘右键菜单「退出」。
|
||||
- 窗口贴屏幕边缘会自动收起为半圆悬浮球, 鼠标停留或单击唤回。
|
||||
- 托盘菜单可开关「开机启动」「边缘吸附」。
|
||||
- 配置保存在 %APPDATA%\\com.iboard.app\\shell-config.json (删除即恢复默认)。
|
||||
|
||||
注意: 便携版不写注册表、不装服务; 但若开启了「开机启动」, 会在
|
||||
HKCU\\...\\Run 写入自启动项, 关闭开关即可移除。
|
||||
`
|
||||
fs.writeFileSync(path.join(portableDir, 'README.txt'), readme, 'utf8')
|
||||
|
||||
const zipName = `${product}_${version}_x64-portable${suffix}.zip`
|
||||
const zipPath = path.join(artifactsDir, zipName)
|
||||
log('打包便携版 zip')
|
||||
const psZip = `Compress-Archive -Path '${portableDir}\\*' -DestinationPath '${zipPath}' -Force`
|
||||
const res = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', psZip], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (res.status !== 0) fail('压缩便携版失败 (Compress-Archive)')
|
||||
|
||||
// ---------- 5. 汇总 ----------
|
||||
const setupExe = path.join(
|
||||
targetDir,
|
||||
'bundle',
|
||||
'nsis',
|
||||
`${product}_${version}_x64-setup${suffix}.exe`,
|
||||
)
|
||||
const outputs = []
|
||||
if (fs.existsSync(setupExe)) {
|
||||
const dest = path.join(artifactsDir, path.basename(setupExe))
|
||||
fs.copyFileSync(setupExe, dest)
|
||||
outputs.push(['安装包 (NSIS)', dest])
|
||||
} else {
|
||||
outputs.push(['安装包 (NSIS)', `未找到: ${setupExe}`])
|
||||
}
|
||||
outputs.push(['便携版 (zip)', zipPath])
|
||||
outputs.push(['便携版 (目录)', `${portableDir} (${fs.readdirSync(portableDir).length} 个文件)`])
|
||||
|
||||
log('构建完成:')
|
||||
for (const [label, p] of outputs) {
|
||||
const size = fs.existsSync(p) ? `${(fs.statSync(p).size / 1024 / 1024).toFixed(1)} MB` : ''
|
||||
console.log(` ${label.padEnd(14)} ${p}${size ? ' (' + size + ')' : ''}`)
|
||||
}
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
// 生成托盘深浅色单色图标 (W-THE-01):
|
||||
// src-tauri/icons/tray-light.png 深色字形 —— 浅色任务栏用
|
||||
// src-tauri/icons/tray-dark.png 白色字形 —— 深色任务栏用
|
||||
//
|
||||
// 做法: 不能只用 alpha 通道做剪影 —— 源图标是「深色圆角方块 + 白色 i」,
|
||||
// 整个方块都是不透明的, 只取 alpha 会得到一块纯色方块, i 字母细节全丢。
|
||||
// 这里按**亮度**映射, 保留字母结构:
|
||||
// 深色字形: alpha = (255-L)/255 → 方块保留深色, 白色 i 镂空透出任务栏
|
||||
// 浅色字形: alpha = max(L,40)/255 → i 白色高亮, 方块留一点淡淡衬底
|
||||
// 产物已提交进仓库, 仅图标变更时需要重跑: node scripts/gen-tray-icons.mjs
|
||||
import sharp from 'sharp'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const src = path.join(root, 'src-tauri', 'icons', '128x128.png')
|
||||
const outDir = path.join(root, 'src-tauri', 'icons')
|
||||
|
||||
const targets = [
|
||||
{ file: 'tray-light.png', rgb: [31, 31, 31], mode: 'dark-glyph' }, // 深色字形 (浅色任务栏)
|
||||
{ file: 'tray-dark.png', rgb: [250, 250, 250], mode: 'light-glyph' }, // 白色字形 (深色任务栏)
|
||||
]
|
||||
|
||||
for (const t of targets) {
|
||||
const { data, info } = await sharp(src)
|
||||
.resize(32, 32, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
const out = Buffer.alloc(info.width * info.height * 4)
|
||||
for (let i = 0; i < info.width * info.height; i++) {
|
||||
const r = data[i * 4]
|
||||
const g = data[i * 4 + 1]
|
||||
const b = data[i * 4 + 2]
|
||||
const a = data[i * 4 + 3]
|
||||
const lumi = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
const alpha =
|
||||
t.mode === 'dark-glyph' ? (a * (255 - lumi)) / 255 : (a * Math.max(lumi, 40)) / 255
|
||||
out[i * 4] = t.rgb[0]
|
||||
out[i * 4 + 1] = t.rgb[1]
|
||||
out[i * 4 + 2] = t.rgb[2]
|
||||
out[i * 4 + 3] = Math.round(alpha)
|
||||
}
|
||||
await sharp(out, { raw: { width: info.width, height: info.height, channels: 4 } })
|
||||
.png()
|
||||
.toFile(path.join(outDir, t.file))
|
||||
console.log('generated', t.file)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ fi
|
||||
cd "$WIN_PROJECT"
|
||||
|
||||
# Android 后端地址, 写到前端 bundle 里 (覆盖默认的 /api 相对路径)
|
||||
export VITE_API_BASE="${VITE_API_BASE:-http://bh.vps.honor3.com:3001}"
|
||||
export VITE_API_BASE="${VITE_API_BASE:-https://iboard.honor3.com}"
|
||||
echo "VITE_API_BASE=$VITE_API_BASE"
|
||||
|
||||
# 1. 先构建前端, 让 Tauri android 工程能拿到 dist/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "iboard"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
description = "iBoard - 经济数据与策略"
|
||||
authors = ["iBoard Team"]
|
||||
edition = "2021"
|
||||
@@ -27,6 +27,20 @@ tauri-plugin-hotswap = "0.0.4"
|
||||
[target."cfg(target_os = \"android\")".dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
|
||||
# Windows 外壳能力 (W-M1~M4): 托盘 / 自启动 / 单实例 / Win32 辅助
|
||||
# 仅加在 Windows 目标上, Android 构建不受影响 (W-NF-05)
|
||||
[target."cfg(windows)".dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
windows-sys = { version = "0.60", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Registry",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[features]
|
||||
# Tauri 的"自定义协议"模式让打包后的 webview 能从 tauri:// 加载资源
|
||||
# 用于生产构建, dev 模式不需要
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 731 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 664 B |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 942 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 730 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 753 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 468 B |
|
After Width: | Height: | Size: 772 B |
|
After Width: | Height: | Size: 772 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 606 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 772 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 345 B |
|
After Width: | Height: | Size: 414 B |
@@ -1,9 +1,14 @@
|
||||
// 跨平台入口: 桌面从这里走, Android 走 mobile_entry_point 宏
|
||||
// 启动流程:
|
||||
// 1) 初始化 tauri-plugin-hotswap (前端 OTA 升级插件)
|
||||
// 1) Windows 外壳能力注册 (单实例 / 自启动 / 托盘 / 吸附悬浮球, 见 src/shell)
|
||||
// 2) 初始化 tauri-plugin-hotswap (前端 OTA 升级插件)
|
||||
// - 必须在 build 之前调用, 插件会替换 tauri::generate_context! 的 asset provider,
|
||||
// 后续可被 tauri::Builder 消费
|
||||
// 2) 业务初始化
|
||||
// 3) 业务初始化
|
||||
|
||||
#[cfg(all(desktop, target_os = "windows"))]
|
||||
mod shell;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// 生成 context, 交给 hotswap 包装 (替换 Assets provider, 让 tauri://localhost 资源
|
||||
@@ -12,8 +17,94 @@ pub fn run() {
|
||||
let (hotswap, context) = tauri_plugin_hotswap::init(context)
|
||||
.expect("failed to initialize tauri-plugin-hotswap");
|
||||
|
||||
tauri::Builder::default()
|
||||
let mut builder = tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
dock_hover_start,
|
||||
dock_hover_end,
|
||||
dock_click,
|
||||
dock_drag_start,
|
||||
dock_drag_move,
|
||||
dock_drag_end,
|
||||
])
|
||||
.on_window_event(on_window_event);
|
||||
|
||||
#[cfg(all(desktop, target_os = "windows"))]
|
||||
{
|
||||
builder = builder
|
||||
// 单实例必须最先注册 (W-TRY-07): 二次启动唤回已有实例
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||
shell::activate_primary(app);
|
||||
}))
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
Some(vec!["--autostart"]),
|
||||
))
|
||||
.setup(|app| shell::setup(app));
|
||||
}
|
||||
|
||||
builder
|
||||
.plugin(hotswap)
|
||||
.run(context)
|
||||
.expect("error while running iBoard");
|
||||
}
|
||||
|
||||
fn on_window_event(window: &tauri::Window, event: &tauri::WindowEvent) {
|
||||
#[cfg(all(desktop, target_os = "windows"))]
|
||||
shell::on_window_event(window, event);
|
||||
#[cfg(not(all(desktop, target_os = "windows")))]
|
||||
{
|
||||
let _ = (window, event);
|
||||
}
|
||||
}
|
||||
|
||||
// —— 悬浮球 IPC (dock.html → Rust); 非 Windows 桌面平台为 no-op ——
|
||||
|
||||
#[tauri::command]
|
||||
fn dock_hover_start(app: tauri::AppHandle) {
|
||||
#[cfg(all(desktop, target_os = "windows"))]
|
||||
shell::dock_hover_start(&app);
|
||||
#[cfg(not(all(desktop, target_os = "windows")))]
|
||||
let _ = app;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn dock_hover_end(app: tauri::AppHandle) {
|
||||
#[cfg(all(desktop, target_os = "windows"))]
|
||||
shell::dock_hover_end(&app);
|
||||
#[cfg(not(all(desktop, target_os = "windows")))]
|
||||
let _ = app;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn dock_click(app: tauri::AppHandle) {
|
||||
#[cfg(all(desktop, target_os = "windows"))]
|
||||
shell::dock_click(&app);
|
||||
#[cfg(not(all(desktop, target_os = "windows")))]
|
||||
let _ = app;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn dock_drag_start(app: tauri::AppHandle) {
|
||||
#[cfg(all(desktop, target_os = "windows"))]
|
||||
shell::dock_drag_start(&app);
|
||||
#[cfg(not(all(desktop, target_os = "windows")))]
|
||||
let _ = app;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn dock_drag_move(app: tauri::AppHandle, dx: i32, dy: i32) {
|
||||
#[cfg(all(desktop, target_os = "windows"))]
|
||||
shell::dock_drag_move(&app, dx, dy);
|
||||
#[cfg(not(all(desktop, target_os = "windows")))]
|
||||
{
|
||||
let _ = (app, dx, dy);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn dock_drag_end(app: tauri::AppHandle) {
|
||||
#[cfg(all(desktop, target_os = "windows"))]
|
||||
shell::dock_drag_end(&app);
|
||||
#[cfg(not(all(desktop, target_os = "windows")))]
|
||||
let _ = app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// 澶栧3璁剧疆鎸佷箙鍖?(W-CFG-01~03): %APPDATA%\com.iboard.app\shell-config.json
|
||||
// - 涓庡墠绔处鍙?鏈嶅姟绔棤鍏? 绂荤嚎鍙敤;
|
||||
// - 瀛楁缂哄け/鎹熷潖鏃跺洖閫€榛樿鍊?(serde(default) + 瀹芥澗鍔犺浇);
|
||||
// - 鍐欏叆閲囩敤 涓存椂鏂囦欢 + 鏇挎崲 鐨勫師瀛愯涔夈€?
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
pub const FILE_NAME: &str = "shell-config.json";
|
||||
|
||||
/// 璁捐鍩哄噯灏哄 (绉诲姩绔竷灞€鍙e緞): 绐楀彛姣斾緥閿佹涓?405:720
|
||||
pub const DESIGN_WIDTH: f64 = 405.0;
|
||||
pub const DESIGN_HEIGHT: f64 = 720.0;
|
||||
/// 窗口宽度可配置范围 (逻辑像素); 高度由比例推导, 不接受单独配置
|
||||
pub const WIDTH_MIN: f64 = 242.0;
|
||||
pub const WIDTH_MAX: f64 = 620.0;
|
||||
|
||||
/// 鎸夐攣瀹氭瘮渚嬬敱瀹藉害鎺ㄥ楂樺害 (鍞竴灏哄鍏ュ彛)
|
||||
pub fn height_for_width(width: f64) -> f64 {
|
||||
let w = width.clamp(WIDTH_MIN, WIDTH_MAX);
|
||||
(w * DESIGN_HEIGHT / DESIGN_WIDTH).round()
|
||||
}
|
||||
|
||||
/// 鍚搁檮杈?(W-SNP-01: 鍥涜竟)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Edge {
|
||||
#[default]
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl Edge {
|
||||
pub fn css(&self) -> &'static str {
|
||||
match self {
|
||||
Edge::Left => "left",
|
||||
Edge::Right => "right",
|
||||
Edge::Top => "top",
|
||||
Edge::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 涓荤獥鍙h蹇?(W-WIN-07): 灏哄涓洪€昏緫鍍忕礌 (DPI 绋冲畾), 浣嶇疆涓虹墿鐞嗗儚绱?(鍔犺浇鏃舵牎楠屽す鍙?
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WindowCfg {
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub x: Option<i32>,
|
||||
pub y: Option<i32>,
|
||||
}
|
||||
|
||||
impl Default for WindowCfg {
|
||||
fn default() -> Self {
|
||||
// 323x574 = 405:720 姣斾緥 (0.797x of 405x720, 2026-10-26 鏀惧ぇ 12%)
|
||||
Self { width: 323.0, height: height_for_width(323.0), x: None, y: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// 鎮诞鐞冨仠闈?(W-SNP-02/12): 杈?+ 娌胯竟浣嶇疆 (鎮诞鐞冧腑蹇? 鐗╃悊鍍忕礌)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DockCfg {
|
||||
pub edge: Edge,
|
||||
pub offset: i32,
|
||||
}
|
||||
|
||||
impl Default for DockCfg {
|
||||
fn default() -> Self {
|
||||
Self { edge: Edge::Left, offset: 360 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ShellConfig {
|
||||
/// 鍚搁檮鎬诲紑鍏?(W-SNP-09, 榛樿寮€鍚?
|
||||
pub snap_enabled: bool,
|
||||
pub window: WindowCfg,
|
||||
pub dock: DockCfg,
|
||||
/// 绐楀彛瀹藉害 (閫昏緫鍍忕礌) 鈥斺€?**鍞竴灏哄閰嶇疆椤?*銆? /// 楂樺害鎸?405:720 閿佸畾姣斾緥鑷姩鎺ㄥ, 鏃犻渶涔熸棤娉曞崟鐙厤缃?
|
||||
/// 鐣欑┖鏃跺洖钀藉埌 window.width銆? #[serde(default)]
|
||||
pub window_width: Option<f64>,
|
||||
/// 閰嶇疆缁撴瀯鐗堟湰: 0 = 鏃х増 (绐楀彛灏哄鎸?405x720 鍙e緞淇濆瓨),
|
||||
/// 1 = 鏁翠綋缂╁皬 30% (288 鍙e緞), 2 = 鏀惧ぇ 12% (323 鍙e緞) + 鍗曞€煎搴﹂厤缃€? /// load 鏃跺仛涓€娆℃€ц縼绉汇€? /// 娉ㄦ剰蹇呴』鐢ㄥ瓧娈电骇 serde(default) (u8 鈫?0): 瀹瑰櫒绾?default 浼氬彇
|
||||
/// ShellConfig::default() 鐨?2, 鏃ч厤缃枃浠跺氨姘歌繙涓嶈縼绉讳簡銆? #[serde(default)]
|
||||
pub cfg_version: u8,
|
||||
}
|
||||
|
||||
impl Default for ShellConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
snap_enabled: true,
|
||||
window: WindowCfg::default(),
|
||||
dock: DockCfg::default(),
|
||||
window_width: Some(WindowCfg::default().width),
|
||||
cfg_version: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 杩佺Щ鍓嶇殑绐楀彛瀹藉害 (鏃у彛寰?, 渚?load 浣跨敤
|
||||
impl WindowCfg {
|
||||
/// 搴旂敤閿佸畾姣斾緥: 瀹藉害澶瑰彇鍒板悎娉曡寖鍥? 楂樺害鐢卞搴︽帹瀵?(蹇界暐瀛樼洏楂樺害)
|
||||
pub(crate) fn normalize(&mut self) {
|
||||
self.width = self.width.clamp(WIDTH_MIN, WIDTH_MAX);
|
||||
self.height = height_for_width(self.width);
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellConfig {
|
||||
pub fn load(dir: &Path) -> ShellConfig {
|
||||
let path = dir.join(FILE_NAME);
|
||||
let mut cfg = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => match serde_json::from_str::<ShellConfig>(&text) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(_) => ShellConfig::default(), // 鍐欏潖 JSON 鈫?榛樿鍊?(W-CFG-03)
|
||||
},
|
||||
Err(_) => ShellConfig::default(),
|
||||
};
|
||||
// 一次性迁移
|
||||
if cfg.cfg_version < 1 {
|
||||
// v0 (405 鍙e緞) 鈫?v1: 鏁翠綋缂╁皬 30%
|
||||
cfg.window.width = (cfg.window.width * 0.7).max(216.0);
|
||||
cfg.window.height = (cfg.window.height * 0.7).max(384.0);
|
||||
cfg.cfg_version = 1;
|
||||
}
|
||||
if cfg.cfg_version < 2 {
|
||||
// v1 (288 口径) -> v2: 放大 12% (323 口径), 引入单值宽度配置
|
||||
cfg.window.width = (cfg.window.width * 1.12).max(WIDTH_MIN);
|
||||
cfg.window.height = height_for_width(cfg.window.width);
|
||||
cfg.cfg_version = 2;
|
||||
}
|
||||
// 鍗曞€煎搴︿紭鍏? 楂樺害涓€寰嬫寜閿佸畾姣斾緥鎺ㄥ (蹇界暐瀛樼洏楂樺害)
|
||||
if let Some(w) = cfg.window_width {
|
||||
cfg.window.width = w;
|
||||
}
|
||||
cfg.window.normalize();
|
||||
cfg.window_width = Some(cfg.window.width);
|
||||
cfg.save(dir);
|
||||
cfg
|
||||
}
|
||||
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let path = dir.join(FILE_NAME);
|
||||
let tmp = dir.join(format!("{FILE_NAME}.tmp"));
|
||||
let text = match serde_json::to_string_pretty(self) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return,
|
||||
};
|
||||
if std::fs::write(&tmp, text).is_ok() {
|
||||
// Windows 上 rename 不能覆盖已存在文件, 先移除
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::rename(&tmp, &path);
|
||||
}
|
||||
// 鍐欏け璐ヤ繚鎸侀潤榛?(W-CFG-04): 杩愯涓殑澶栧3涓嶅彈褰卞搷
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,960 @@
|
||||
//! Windows 外壳 (需求文档 §3): 托盘常驻 / 开机自启动 / 四边吸附悬浮球 / 主题跟随
|
||||
//!
|
||||
//! 仅在 `desktop + windows` 下编译 (lib.rs cfg 门控); Android 构建不含本模块。
|
||||
//! 状态机要点:
|
||||
//! - 收起判定在「松开鼠标」后静默 150ms 进行 (W-SNP-01, 评审决策 #1);
|
||||
//! - 悬浮球 hover 停留 ≥150ms 或单击即恢复 (W-SNP-04/05, 决策 #2);
|
||||
//! - 悬浮球可沿边拖动、拖到其他边换边、拖离边缘 ≥32px 拖出主窗口 (W-SNP-12~14);
|
||||
//! - 无边框全屏之上保持显示, 独占全屏自动避让 (W-SNP-15/16);
|
||||
//! - 托盘与悬浮球深浅色跟随系统 (W-THE)。
|
||||
|
||||
mod config;
|
||||
mod win;
|
||||
|
||||
use config::{DockCfg, Edge, ShellConfig};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::menu::{CheckMenuItem, Menu, MenuEvent, MenuItem};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindowBuilder, Wry};
|
||||
|
||||
const MAIN: &str = "main";
|
||||
const DOCK: &str = "dock";
|
||||
const TRAY_ID: &str = "iboard-tray";
|
||||
|
||||
/// 悬浮球尺寸 (逻辑像素, 附录 A): 垂直边 = 28×56, 水平边 = 56×28
|
||||
const HANDLE_SHORT: f64 = 28.0;
|
||||
const HANDLE_LONG: f64 = 56.0;
|
||||
/// 吸附触发阈值 (W-SNP-01)
|
||||
const SNAP_PX: f64 = 8.0;
|
||||
/// 解除吸附阈值 (W-SNP-06/14)
|
||||
const UNDOCK_PX: f64 = 32.0;
|
||||
/// 碰触恢复延时 (W-SNP-04)
|
||||
const RESTORE_DELAY: Duration = Duration::from_millis(150);
|
||||
/// 吸附态展开后, 鼠标移开窗口多久自动收起 (auto-hide)
|
||||
const AUTOHIDE_DELAY: Duration = Duration::from_millis(400);
|
||||
/// 移动静默判定窗口 (松手判定, 决策 #1)
|
||||
const MOVE_QUIET: Duration = Duration::from_millis(150);
|
||||
/// 程序性移动后的吸附抑制 (避免 set_position 触发立即收起)
|
||||
const MOVE_SUPPRESS: Duration = Duration::from_millis(400);
|
||||
/// 尺寸比例锁容差 (逻辑像素)
|
||||
const RATIO_TOL: f64 = 2.0;
|
||||
|
||||
type CheckItem = CheckMenuItem<Wry>;
|
||||
|
||||
struct Shared {
|
||||
data_dir: PathBuf,
|
||||
cfg: ShellConfig,
|
||||
/// 主窗口处于吸附态 (含贴边可见的“已恢复”状态)
|
||||
docked: bool,
|
||||
/// 悬浮球当前可见 (true = 收起态)
|
||||
dock_visible: bool,
|
||||
/// 因独占全屏而隐藏 (W-SNP-16)
|
||||
fs_hidden: bool,
|
||||
/// 悬浮球拖动中 (JS pointer 事件驱动)
|
||||
dragging: bool,
|
||||
hover_since: Option<Instant>,
|
||||
last_main_move: Option<Instant>,
|
||||
/// 最近一次移动的 (最近边, 距边缘距离), 供解除吸附的静默判定
|
||||
last_move_eval: Option<(Edge, f64)>,
|
||||
/// 是否已离开边缘条带 (拖离过程中置位; 只有 armed 才允许再次触碰收起,
|
||||
/// 否则恢复到贴边位置后的第一次移动事件会立即误收)
|
||||
edge_armed: bool,
|
||||
/// 上一次移动事件时的窗口矩形 (越线判定用)
|
||||
last_main_rect: Option<Rect>,
|
||||
/// 吸附态展开后, 光标移出窗口的起始时刻 (auto-hide 计时)
|
||||
mouse_outside_since: Option<Instant>,
|
||||
/// 展开后光标是否进过窗口 (悬浮球唤回=true; 托盘唤回时光标在远处=false,
|
||||
/// 未武装则永不自动收起, 否则托盘唤回会立刻缩回去)
|
||||
mouse_inside_since_restore: bool,
|
||||
ignore_until: Option<Instant>,
|
||||
theme_dark: bool,
|
||||
menu_autostart: Option<CheckItem>,
|
||||
menu_snap: Option<CheckItem>,
|
||||
/// 主窗口在吸附态下的恢复位置 (贴边, 物理像素)
|
||||
docked_rect: Option<(PhysicalPosition<i32>, PhysicalSize<u32>)>,
|
||||
}
|
||||
|
||||
type State = Mutex<Shared>;
|
||||
|
||||
fn state<'a>(app: &'a AppHandle) -> Option<&'a State> {
|
||||
app.try_state::<State>().map(|m| m.inner())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期
|
||||
// ============================================================================
|
||||
|
||||
pub fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let silent = std::env::args().any(|a| a == "--autostart"); // W-AUT-04 静默启动
|
||||
let data_dir = app.path().app_data_dir()?;
|
||||
let _ = std::fs::create_dir_all(&data_dir);
|
||||
let cfg = ShellConfig::load(&data_dir);
|
||||
|
||||
let main = app.get_webview_window(MAIN).expect("main window missing");
|
||||
apply_saved_window(&main, &cfg);
|
||||
|
||||
// 内容缩放跟随窗口宽度: 布局视口始终等于 405 设计宽, 窗口变大/变小只是整体等比缩放.
|
||||
// 仅主窗口 —— 悬浮球窗口自绘尺寸不缩放; Android 端不经过本模块, 不受影响。
|
||||
let _ = main.set_zoom(cfg.window.width / config::DESIGN_WIDTH);
|
||||
|
||||
// 悬浮球窗口 (W-SNP-02): 透明 / 置顶 / 无任务栏条目, 初始隐藏
|
||||
WebviewWindowBuilder::new(app, DOCK, WebviewUrl::App("dock.html".into()))
|
||||
.title("iBoard Dock")
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.shadow(false)
|
||||
.resizable(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.focused(false)
|
||||
.visible(false)
|
||||
.build()?;
|
||||
|
||||
let dark = win::system_prefers_dark();
|
||||
let (menu_autostart, menu_snap) = build_tray(app, &cfg, dark)?;
|
||||
|
||||
let state = Mutex::new(Shared {
|
||||
data_dir,
|
||||
cfg,
|
||||
docked: false,
|
||||
dock_visible: false,
|
||||
fs_hidden: false,
|
||||
dragging: false,
|
||||
hover_since: None,
|
||||
last_main_move: None,
|
||||
last_move_eval: None,
|
||||
edge_armed: true,
|
||||
last_main_rect: None,
|
||||
mouse_outside_since: None,
|
||||
mouse_inside_since_restore: false,
|
||||
ignore_until: None,
|
||||
theme_dark: dark,
|
||||
menu_autostart: Some(menu_autostart),
|
||||
menu_snap: Some(menu_snap),
|
||||
docked_rect: None,
|
||||
});
|
||||
app.manage(state);
|
||||
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || checker_loop(handle));
|
||||
|
||||
if !silent {
|
||||
let _ = main.show();
|
||||
let _ = main.set_focus();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_tray(
|
||||
app: &tauri::App,
|
||||
cfg: &ShellConfig,
|
||||
dark: bool,
|
||||
) -> Result<(CheckItem, CheckItem), Box<dyn std::error::Error>> {
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
let autostart_on = app.autolaunch().is_enabled().unwrap_or(false); // W-AUT-03 真实状态
|
||||
|
||||
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
|
||||
let autostart = CheckMenuItem::with_id(app, "autostart", "开机启动", true, autostart_on, None::<&str>)?;
|
||||
let snap = CheckMenuItem::with_id(app, "snap", "边缘吸附", true, cfg.snap_enabled, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show, &autostart, &snap, &quit])?;
|
||||
|
||||
TrayIconBuilder::with_id(TRAY_ID)
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false) // 左键留给唤回 (W-TRY-03), 右键出菜单 (W-TRY-05)
|
||||
.icon(tray_icon_image(dark)?)
|
||||
.tooltip("iBoard")
|
||||
.on_menu_event(on_menu_event)
|
||||
.on_tray_icon_event(on_tray_event)
|
||||
.build(app)?;
|
||||
Ok((autostart, snap))
|
||||
}
|
||||
|
||||
fn checker_loop(app: AppHandle) {
|
||||
let mut tick: u64 = 0;
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(80));
|
||||
tick += 1;
|
||||
let now = Instant::now();
|
||||
let action = {
|
||||
let Some(st) = state(&app) else { continue };
|
||||
let mut s = st.lock().expect("shell state poisoned");
|
||||
decide(&mut s, now)
|
||||
};
|
||||
match action {
|
||||
Action::Restore => restore_main(&app),
|
||||
Action::Undock => undock(&app, None),
|
||||
Action::None => {}
|
||||
}
|
||||
autohide_check(&app);
|
||||
if tick % 6 == 0 {
|
||||
fullscreen_check(&app); // ~480ms
|
||||
}
|
||||
if tick % 12 == 0 {
|
||||
theme_check(&app); // ~1s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Action {
|
||||
None,
|
||||
Restore,
|
||||
Undock,
|
||||
}
|
||||
|
||||
fn decide(s: &mut Shared, now: Instant) -> Action {
|
||||
// 1) 移动静默判定仅用于「解除吸附」(W-SNP-05/06); 收起改为触碰即收起
|
||||
// (见 record_main_move, 修订决策 #1)
|
||||
if let Some(t) = s.last_main_move {
|
||||
if now.duration_since(t) >= MOVE_QUIET && s.ignore_until.map_or(true, |u| now >= u) {
|
||||
s.last_main_move = None;
|
||||
if s.cfg.snap_enabled {
|
||||
if let Some((_edge, dist)) = s.last_move_eval {
|
||||
if s.docked && !s.dock_visible && s.edge_armed && dist >= UNDOCK_PX {
|
||||
return Action::Undock; // 拖离 → 解除吸附 (W-SNP-05)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2) 悬浮球碰触延时恢复 (W-SNP-04)
|
||||
if s.dock_visible && !s.dragging {
|
||||
if let Some(t0) = s.hover_since {
|
||||
if now.duration_since(t0) >= RESTORE_DELAY {
|
||||
s.hover_since = None;
|
||||
return Action::Restore;
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::None
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 事件入口 (lib.rs 转发)
|
||||
// ============================================================================
|
||||
|
||||
pub fn on_window_event(window: &tauri::Window, event: &tauri::WindowEvent) {
|
||||
if window.label() != MAIN {
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
WindowEvent::CloseRequested { api, .. } => {
|
||||
// W-TRY-02: 关闭到托盘, 进程不退出
|
||||
api.prevent_close();
|
||||
save_window_cfg(window.app_handle());
|
||||
let _ = window.hide();
|
||||
}
|
||||
WindowEvent::Resized(size) => ratio_lock(window, *size),
|
||||
WindowEvent::Moved(pos) => record_main_move(window, *pos),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
use tauri::WindowEvent;
|
||||
|
||||
fn record_main_move(window: &tauri::Window, pos: PhysicalPosition<i32>) {
|
||||
let app = window.app_handle();
|
||||
let Some(st) = state(app) else { return };
|
||||
// 程序性移动 (恢复/解除吸附的 set_position) 期间不记录,
|
||||
// 否则抑制期一过会把"恢复到贴边位置"误判成用户再次贴边 (W-SNP-04)
|
||||
if st
|
||||
.lock()
|
||||
.expect("shell state poisoned")
|
||||
.ignore_until
|
||||
.map_or(false, |u| Instant::now() < u)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(wa) = window
|
||||
.hwnd()
|
||||
.ok()
|
||||
.map(|h| win::workarea_for_hwnd(h.0))
|
||||
.flatten()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Ok(size) = window.outer_size() else { return };
|
||||
let rect = rect_of(pos, size);
|
||||
let (near_edge, dist) = nearest_edge(&rect, &wa);
|
||||
let prev_rect = st.lock().expect("shell state poisoned").last_main_rect;
|
||||
let touching = edge_touching(prev_rect.as_ref(), &rect, &wa);
|
||||
|
||||
let do_collapse = {
|
||||
let mut s = st.lock().expect("shell state poisoned");
|
||||
// 触碰即收起 (修订决策 #1): 拖动中窗口边缘一进入边缘条带就收起,
|
||||
// 不做静默等待 —— 天然免疫 Win11 Aero Snap 抢跑与不可见边框过冲。
|
||||
// 「入带 + 越线」双条件: 停泊在边缘以外 (如压住任务栏区) 的窗口不受影响。
|
||||
match touching {
|
||||
Some(edge) => {
|
||||
let go = s.cfg.snap_enabled && !s.dock_visible && s.edge_armed;
|
||||
if go {
|
||||
s.edge_armed = false;
|
||||
s.last_main_move = None;
|
||||
s.last_main_rect = None;
|
||||
}
|
||||
go.then_some(edge)
|
||||
}
|
||||
None => {
|
||||
s.edge_armed = true; // 离开边缘条带 → 重新武装
|
||||
s.last_main_move = Some(Instant::now());
|
||||
s.last_move_eval = Some((near_edge, dist));
|
||||
s.last_main_rect = Some(rect);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(edge) = do_collapse {
|
||||
collapse(app, edge);
|
||||
}
|
||||
}
|
||||
|
||||
/// 9:16 比例锁 (W-WIN-02/03): 宽度为驱动, 高度 = 宽 × 16/9, 上限 = 可工作区高 × 95%
|
||||
fn ratio_lock(window: &tauri::Window, actual: PhysicalSize<u32>) {
|
||||
if window.is_minimized().unwrap_or(true) || actual.width == 0 || actual.height == 0 {
|
||||
return;
|
||||
}
|
||||
let Ok(scale) = window.scale_factor() else { return };
|
||||
let wa_h: i32 = window
|
||||
.hwnd()
|
||||
.ok()
|
||||
.and_then(|h| win::workarea_for_hwnd(h.0))
|
||||
.map(|wa| wa.height())
|
||||
.or_else(|| {
|
||||
window
|
||||
.current_monitor()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|m| m.size().height as i32)
|
||||
})
|
||||
.unwrap_or(1080);
|
||||
let max_h = (wa_h as f64 * 0.95) as u32;
|
||||
let min_w = (216.0 * scale).round() as u32;
|
||||
let mut w = actual.width;
|
||||
let mut h = w * 16 / 9;
|
||||
if h > max_h {
|
||||
h = max_h;
|
||||
w = h * 9 / 16;
|
||||
}
|
||||
if w < min_w {
|
||||
w = min_w;
|
||||
h = w * 16 / 9;
|
||||
if h > max_h {
|
||||
h = max_h;
|
||||
w = h * 9 / 16;
|
||||
}
|
||||
}
|
||||
let tol = (RATIO_TOL * scale).ceil() as i64;
|
||||
if (actual.width as i64 - w as i64).abs() > tol || (actual.height as i64 - h as i64).abs() > tol {
|
||||
let _ = window.set_size(PhysicalSize::new(w, h));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 吸附 / 恢复 / 解除
|
||||
// ============================================================================
|
||||
|
||||
/// 收起 (W-SNP-01/02): 主窗口贴边隐藏, 悬浮球出现在边缘。
|
||||
/// edge 由 record_main_move 的触碰判定给出, 不在此重判。
|
||||
fn collapse(app: &AppHandle, edge: Edge) {
|
||||
let Some(main) = app.get_webview_window(MAIN) else { return };
|
||||
let Ok(hwnd) = main.hwnd() else { return };
|
||||
let Some(wa) = win::workarea_for_hwnd(hwnd.0) else { return };
|
||||
let Ok(scale) = main.scale_factor() else { return };
|
||||
let (Ok(pos), Ok(size)) = (main.outer_position(), main.outer_size()) else { return };
|
||||
let rect = rect_of(pos, size);
|
||||
let docked_pos = flush_pos(&rect, edge, &wa);
|
||||
let offset = clamp_offset(
|
||||
along_center(&rect, edge),
|
||||
edge,
|
||||
&wa,
|
||||
HANDLE_LONG,
|
||||
scale,
|
||||
);
|
||||
{
|
||||
let Some(st) = state(app) else { return };
|
||||
let mut s = st.lock().expect("shell state poisoned");
|
||||
s.docked = true;
|
||||
s.mouse_outside_since = None;
|
||||
s.cfg.dock = DockCfg { edge, offset };
|
||||
s.docked_rect = Some((docked_pos, size));
|
||||
let cfg = s.cfg.clone();
|
||||
let dir = s.data_dir.clone();
|
||||
drop(s);
|
||||
cfg.save(&dir);
|
||||
}
|
||||
let _ = main.set_position(PhysicalPosition::new(docked_pos.x, docked_pos.y));
|
||||
let _ = main.hide();
|
||||
place_handle(app, edge, offset, &wa, scale);
|
||||
}
|
||||
|
||||
/// 悬浮球定位 + 显示 (W-SNP-02/03)
|
||||
fn place_handle(app: &AppHandle, edge: Edge, offset: i32, wa: &win::WorkArea, scale: f64) {
|
||||
let Some(dock) = app.get_webview_window(DOCK) else { return };
|
||||
let (lw, lh) = match edge {
|
||||
Edge::Left | Edge::Right => (HANDLE_SHORT, HANDLE_LONG),
|
||||
Edge::Top | Edge::Bottom => (HANDLE_LONG, HANDLE_SHORT),
|
||||
};
|
||||
let w = (lw * scale).round() as i32;
|
||||
let h = (lh * scale).round() as i32;
|
||||
let (x, y) = match edge {
|
||||
Edge::Left => (wa.left, wa.clamp_y(offset - h / 2, h)),
|
||||
Edge::Right => (wa.right - w, wa.clamp_y(offset - h / 2, h)),
|
||||
Edge::Top => (wa.clamp_x(offset - w / 2, w), wa.top),
|
||||
Edge::Bottom => (wa.clamp_x(offset - w / 2, w), wa.bottom - h),
|
||||
};
|
||||
let dark = state(app)
|
||||
.map(|s| s.lock().expect("shell state poisoned").theme_dark)
|
||||
.unwrap_or(false);
|
||||
let _ = dock.set_size(PhysicalSize::new(w as u32, h as u32));
|
||||
let _ = dock.set_position(PhysicalPosition::new(x, y));
|
||||
let _ = dock.eval(&format!(
|
||||
"window.__SCALE = {scale}; window.applyDock('{}', '{}')",
|
||||
edge.css(),
|
||||
if dark { "dark" } else { "light" }
|
||||
));
|
||||
let _ = dock.show();
|
||||
if let Some(st) = state(app) {
|
||||
st.lock().expect("shell state poisoned").dock_visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 唤回主窗口 (W-SNP-04/05, W-EX-01): 恢复到吸附位置, 悬浮球消失
|
||||
fn restore_main(app: &AppHandle) {
|
||||
let Some(st) = state(app) else { return };
|
||||
let docked_rect = {
|
||||
let mut s = st.lock().expect("shell state poisoned");
|
||||
if !s.docked {
|
||||
return;
|
||||
}
|
||||
s.dock_visible = false;
|
||||
s.hover_since = None;
|
||||
s.edge_armed = false; // 恢复位置贴边, 需先拖离才允许再次触碰收起
|
||||
s.last_main_rect = None;
|
||||
s.mouse_outside_since = None;
|
||||
// auto-hide 武装: 恢复时光标是否已在窗口区域内
|
||||
// (悬浮球唤回 = 在; 托盘/单实例唤回 = 光标在远处, 不武装, 否则立刻缩回)
|
||||
s.mouse_inside_since_restore = win::cursor_pos().map_or(false, |(cx, cy)| {
|
||||
s.docked_rect.map_or(false, |(p, sz)| {
|
||||
cx >= p.x
|
||||
&& cx <= p.x + sz.width as i32
|
||||
&& cy >= p.y
|
||||
&& cy <= p.y + sz.height as i32
|
||||
})
|
||||
});
|
||||
s.ignore_until = Some(Instant::now() + MOVE_SUPPRESS);
|
||||
s.docked_rect
|
||||
};
|
||||
let Some((pos, _size)) = docked_rect else { return };
|
||||
let Some(main) = app.get_webview_window(MAIN) else { return };
|
||||
let Some(dock) = app.get_webview_window(DOCK) else { return };
|
||||
let _ = dock.hide();
|
||||
if let Ok(hwnd) = main.hwnd() {
|
||||
if let Some(wa) = win::workarea_for_hwnd(hwnd.0) {
|
||||
if let Ok(size) = main.outer_size() {
|
||||
let x = wa.clamp_x(pos.x, size.width as i32);
|
||||
let y = wa.clamp_y(pos.y, size.height as i32);
|
||||
let _ = main.set_position(PhysicalPosition::new(x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = main.unminimize();
|
||||
let _ = main.show();
|
||||
let _ = main.set_focus();
|
||||
}
|
||||
|
||||
/// 解除吸附 (W-SNP-05/14): drop = 拖出时的释放点 (悬浮球中心, 物理像素)
|
||||
fn undock(app: &AppHandle, drop: Option<(i32, i32)>) {
|
||||
let Some(st) = state(app) else { return };
|
||||
{
|
||||
let mut s = st.lock().expect("shell state poisoned");
|
||||
s.docked = false;
|
||||
s.dock_visible = false;
|
||||
s.hover_since = None;
|
||||
s.edge_armed = true; // 自由窗口, 触碰边缘可再次收起
|
||||
s.last_main_rect = None;
|
||||
s.ignore_until = Some(Instant::now() + MOVE_SUPPRESS);
|
||||
}
|
||||
if let Some(dock) = app.get_webview_window(DOCK) {
|
||||
let _ = dock.hide();
|
||||
}
|
||||
let Some(main) = app.get_webview_window(MAIN) else { return };
|
||||
if let Some((cx, cy)) = drop {
|
||||
// 主窗口以释放点为中心, 完整夹取进可工作区 (W-SNP-14)
|
||||
let Ok(size) = main.outer_size() else { return };
|
||||
if let Ok(hwnd) = main.hwnd() {
|
||||
if let Some(wa) = win::workarea_for_hwnd(hwnd.0) {
|
||||
let x = wa.clamp_x(cx - size.width as i32 / 2, size.width as i32);
|
||||
let y = wa.clamp_y(cy - size.height as i32 / 2, size.height as i32);
|
||||
let _ = main.set_position(PhysicalPosition::new(x, y));
|
||||
}
|
||||
}
|
||||
let _ = main.show();
|
||||
let _ = main.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 托盘 / 菜单 / 退出
|
||||
// ============================================================================
|
||||
|
||||
fn on_menu_event(app: &AppHandle, event: MenuEvent) {
|
||||
match event.id().as_ref() {
|
||||
"show" => activate_primary(app),
|
||||
"quit" => quit(app),
|
||||
"autostart" => toggle_autostart(app),
|
||||
"snap" => toggle_snap(app),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_tray_event(tray: &tauri::tray::TrayIcon, event: TrayIconEvent) {
|
||||
match event {
|
||||
// 单击与双击均唤回主窗口, 幂等 (W-TRY-03/04)
|
||||
TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } => {
|
||||
activate_primary(tray.app_handle());
|
||||
}
|
||||
TrayIconEvent::DoubleClick { button: MouseButton::Left, .. } => {
|
||||
activate_primary(tray.app_handle());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 唤回入口统一处理: 收起态 → 恢复到吸附位置 (W-EX-01); 否则前置主窗口
|
||||
pub fn activate_primary(app: &AppHandle) {
|
||||
let docked = state(app)
|
||||
.map(|s| s.lock().expect("shell state poisoned").docked)
|
||||
.unwrap_or(false);
|
||||
if docked {
|
||||
restore_main(app);
|
||||
return;
|
||||
}
|
||||
if let Some(main) = app.get_webview_window(MAIN) {
|
||||
let _ = main.unminimize();
|
||||
let _ = main.show();
|
||||
let _ = main.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_autostart(app: &AppHandle) {
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
let on = app.autolaunch().is_enabled().unwrap_or(false);
|
||||
let result = if on {
|
||||
app.autolaunch().disable()
|
||||
} else {
|
||||
app.autolaunch().enable()
|
||||
};
|
||||
// 写入失败时如实回显 (W-EX-06), 不静默假成功
|
||||
let new_on = if on { false } else { result.is_ok() };
|
||||
if let Some(st) = state(app) {
|
||||
let s = st.lock().expect("shell state poisoned");
|
||||
if let Some(item) = &s.menu_autostart {
|
||||
let _ = item.set_checked(new_on);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_snap(app: &AppHandle) {
|
||||
let Some(st) = state(app) else { return };
|
||||
let (on, cfg, dir) = {
|
||||
let mut s = st.lock().expect("shell state poisoned");
|
||||
s.cfg.snap_enabled = !s.cfg.snap_enabled;
|
||||
if let Some(item) = &s.menu_snap {
|
||||
let _ = item.set_checked(s.cfg.snap_enabled);
|
||||
}
|
||||
(s.cfg.snap_enabled, s.cfg.clone(), s.data_dir.clone())
|
||||
};
|
||||
let _ = on;
|
||||
cfg.save(&dir);
|
||||
}
|
||||
|
||||
/// 唯一正常退出入口 (W-TRY-06 / W-EX-02): 先收 UI, 再保存配置, 最后退出
|
||||
pub fn quit(app: &AppHandle) {
|
||||
if let Some(dock) = app.get_webview_window(DOCK) {
|
||||
let _ = dock.hide();
|
||||
}
|
||||
if let Some(main) = app.get_webview_window(MAIN) {
|
||||
save_window_cfg(app);
|
||||
let _ = main.hide();
|
||||
}
|
||||
if let Some(st) = state(app) {
|
||||
let s = st.lock().expect("shell state poisoned");
|
||||
s.cfg.save(&s.data_dir);
|
||||
}
|
||||
let _ = app.remove_tray_by_id(TRAY_ID);
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 悬浮球 IPC (dock.html → Rust)
|
||||
// ============================================================================
|
||||
|
||||
pub fn dock_hover_start(app: &AppHandle) {
|
||||
if let Some(st) = state(app) {
|
||||
st.lock().expect("shell state poisoned").hover_since = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dock_hover_end(app: &AppHandle) {
|
||||
if let Some(st) = state(app) {
|
||||
st.lock().expect("shell state poisoned").hover_since = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// 单击/触屏点按 → 立即恢复 (W-SNP-05)
|
||||
pub fn dock_click(app: &AppHandle) {
|
||||
let docked = state(app)
|
||||
.map(|s| s.lock().expect("shell state poisoned").docked)
|
||||
.unwrap_or(false);
|
||||
if docked {
|
||||
restore_main(app);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dock_drag_start(app: &AppHandle) {
|
||||
if let Some(st) = state(app) {
|
||||
let mut s = st.lock().expect("shell state poisoned");
|
||||
s.dragging = true;
|
||||
s.hover_since = None; // 拖动取消未决的 hover 恢复
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dock_drag_move(app: &AppHandle, dx: i32, dy: i32) {
|
||||
let Some(dock) = app.get_webview_window(DOCK) else { return };
|
||||
if let Ok(pos) = dock.outer_position() {
|
||||
let _ = dock.set_position(PhysicalPosition::new(pos.x + dx, pos.y + dy));
|
||||
}
|
||||
}
|
||||
|
||||
/// 拖动结束: ≤8px 停靠该边 (含换边 W-SNP-13); 8~32px 吸附到最近边; ≥32px 拖出恢复 (W-SNP-14)
|
||||
pub fn dock_drag_end(app: &AppHandle) {
|
||||
{
|
||||
let Some(st) = state(app) else { return };
|
||||
st.lock().expect("shell state poisoned").dragging = false;
|
||||
}
|
||||
let Some(dock) = app.get_webview_window(DOCK) else { return };
|
||||
let (Ok(pos), Ok(size)) = (dock.outer_position(), dock.outer_size()) else { return };
|
||||
let Ok(hwnd) = dock.hwnd() else { return };
|
||||
let Some(wa) = win::workarea_for_hwnd(hwnd.0) else { return };
|
||||
let Ok(scale) = dock.scale_factor() else { return };
|
||||
let rect = rect_of(pos, size);
|
||||
let (edge, dist) = nearest_edge(&rect, &wa);
|
||||
if dist >= UNDOCK_PX {
|
||||
let center = (rect.left + rect.width() / 2, rect.top + rect.height() / 2);
|
||||
undock(app, Some(center));
|
||||
return;
|
||||
}
|
||||
// 停靠 (含 8~32px 死区: 吸附到最近边, 悬浮球不悬空)
|
||||
let offset = clamp_offset(along_center(&rect, edge), edge, &wa, HANDLE_LONG, scale);
|
||||
{
|
||||
let Some(st) = state(app) else { return };
|
||||
let mut s = st.lock().expect("shell state poisoned");
|
||||
s.docked = true;
|
||||
s.mouse_outside_since = None;
|
||||
s.cfg.dock = DockCfg { edge, offset };
|
||||
// 主窗口恢复位置改为新边的贴边矩形 (W-SNP-13: 下次恢复出现在新边缘)
|
||||
let main_size = s
|
||||
.docked_rect
|
||||
.map(|(_, sz)| sz)
|
||||
.or_else(|| app.get_webview_window(MAIN).and_then(|m| m.outer_size().ok()));
|
||||
if let Some(msize) = main_size {
|
||||
let (x, y) = main_flush_from_offset(edge, offset, msize, &wa);
|
||||
s.docked_rect = Some((PhysicalPosition::new(x, y), msize));
|
||||
}
|
||||
let cfg = s.cfg.clone();
|
||||
let dir = s.data_dir.clone();
|
||||
drop(s);
|
||||
cfg.save(&dir);
|
||||
}
|
||||
place_handle(app, edge, offset, &wa, scale);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 全屏避让 / 主题跟随
|
||||
// ============================================================================
|
||||
|
||||
/// 独占全屏 → 隐藏悬浮球; 退出 → 原位恢复 (W-SNP-16)。无边框全屏不受影响 (W-SNP-15)。
|
||||
fn fullscreen_check(app: &AppHandle) {
|
||||
let exclusive = win::is_exclusive_fullscreen();
|
||||
let Some(st) = state(app) else { return };
|
||||
let (transition, was_visible) = {
|
||||
let mut s = st.lock().expect("shell state poisoned");
|
||||
let mut t = None;
|
||||
if exclusive && s.dock_visible && !s.fs_hidden {
|
||||
s.fs_hidden = true;
|
||||
t = Some(false);
|
||||
} else if !exclusive && s.fs_hidden {
|
||||
s.fs_hidden = false;
|
||||
t = Some(true);
|
||||
}
|
||||
(t, s.dock_visible)
|
||||
};
|
||||
match transition {
|
||||
Some(false) => {
|
||||
if let Some(d) = app.get_webview_window(DOCK) {
|
||||
let _ = d.hide();
|
||||
}
|
||||
}
|
||||
Some(true) => {
|
||||
if was_visible {
|
||||
if let Some(d) = app.get_webview_window(DOCK) {
|
||||
let _ = d.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 吸附态已展开 (悬浮球唤回) 时, 光标移开窗口 → 自动收起 (auto-hide 停靠交互)。
|
||||
/// 仅当光标在展开后进过窗口才武装 —— 托盘/单实例唤回时光标在远处, 不缩回。
|
||||
fn autohide_check(app: &AppHandle) {
|
||||
let now = Instant::now();
|
||||
let armed = {
|
||||
let Some(st) = state(app) else { return };
|
||||
let s = st.lock().expect("shell state poisoned");
|
||||
if !s.docked || s.dock_visible || s.dragging {
|
||||
return;
|
||||
}
|
||||
if s.ignore_until.map_or(false, |u| now < u) {
|
||||
return;
|
||||
}
|
||||
// 窗口正在移动 (拖拽中) 时不判定, 避免拖拽途中误收
|
||||
if s.last_main_move.map_or(false, |t| now.duration_since(t) < MOVE_QUIET * 2) {
|
||||
return;
|
||||
}
|
||||
s.mouse_inside_since_restore
|
||||
};
|
||||
if !armed {
|
||||
return;
|
||||
}
|
||||
let Some(main) = app.get_webview_window(MAIN) else { return };
|
||||
let (Ok(pos), Ok(size)) = (main.outer_position(), main.outer_size()) else { return };
|
||||
let Some((cx, cy)) = win::cursor_pos() else { return };
|
||||
let inside = cx >= pos.x
|
||||
&& cx <= pos.x + size.width as i32
|
||||
&& cy >= pos.y
|
||||
&& cy <= pos.y + size.height as i32;
|
||||
let outside_since = {
|
||||
let Some(st) = state(app) else { return };
|
||||
let mut s = st.lock().expect("shell state poisoned");
|
||||
if inside {
|
||||
s.mouse_outside_since = None;
|
||||
None
|
||||
} else {
|
||||
Some(*s.mouse_outside_since.get_or_insert(now))
|
||||
}
|
||||
};
|
||||
if let Some(t) = outside_since {
|
||||
if now.duration_since(t) >= AUTOHIDE_DELAY {
|
||||
let edge = {
|
||||
let Some(st) = state(app) else { return };
|
||||
st.lock().expect("shell state poisoned").cfg.dock.edge
|
||||
};
|
||||
collapse(app, edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 深浅色跟随系统 (W-THE): 托盘图标 + 悬浮球外观
|
||||
fn theme_check(app: &AppHandle) {
|
||||
let dark = win::system_prefers_dark();
|
||||
let changed = {
|
||||
let Some(st) = state(app) else { return };
|
||||
let mut s = st.lock().expect("shell state poisoned");
|
||||
if s.theme_dark == dark {
|
||||
false
|
||||
} else {
|
||||
s.theme_dark = dark;
|
||||
true
|
||||
}
|
||||
};
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
if let Ok(icon) = tray_icon_image(dark) {
|
||||
if let Some(tray) = app.tray_by_id(TRAY_ID) {
|
||||
let _ = tray.set_icon(Some(icon));
|
||||
}
|
||||
}
|
||||
if let Some(d) = app.get_webview_window(DOCK) {
|
||||
let _ = d.eval(&format!("window.applyTheme('{}')", if dark { "dark" } else { "light" }));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 配置持久化
|
||||
// ============================================================================
|
||||
|
||||
/// 启动时应用记忆的窗口尺寸/位置 (W-WIN-07)
|
||||
/// 尺寸只取宽度, 高度按 405:720 锁定比例推导 (见 config::height_for_width)
|
||||
fn apply_saved_window(main: &tauri::WebviewWindow, cfg: &ShellConfig) {
|
||||
let Ok(scale) = main.scale_factor() else { return };
|
||||
let w = cfg.window.width.clamp(config::WIDTH_MIN, config::WIDTH_MAX);
|
||||
let h = config::height_for_width(w);
|
||||
let _ = main.set_size(tauri::LogicalSize::new(w, h));
|
||||
if let (Some(x), Some(y)) = (cfg.window.x, cfg.window.y) {
|
||||
let pw = (w * scale).round() as i32;
|
||||
let ph = (h * scale).round() as i32;
|
||||
if let Some(wa) = win::workarea_for_point(x + pw / 2, y + ph / 2) {
|
||||
let nx = wa.clamp_x(x, pw);
|
||||
let ny = wa.clamp_y(y, ph);
|
||||
let _ = main.set_position(PhysicalPosition::new(nx, ny));
|
||||
}
|
||||
// 无效位置 → 保持配置默认居中 (W-WIN-07)
|
||||
}
|
||||
}
|
||||
|
||||
/// 关闭/退出时保存窗口尺寸与位置 (W-WIN-07)
|
||||
fn save_window_cfg(app: &AppHandle) {
|
||||
let Some(main) = app.get_webview_window(MAIN) else { return };
|
||||
let Some(st) = state(app) else { return };
|
||||
let Ok(scale) = main.scale_factor() else { return };
|
||||
let (Ok(size), Ok(pos)) = (main.outer_size(), main.outer_position()) else { return };
|
||||
let cfg = {
|
||||
let mut s = st.lock().expect("shell state poisoned");
|
||||
// 只记宽度; 高度按锁定比例推导, 保证下次启动比例不变
|
||||
s.cfg.window.width = size.width as f64 / scale;
|
||||
s.cfg.window.normalize();
|
||||
s.cfg.window_width = Some(s.cfg.window.width);
|
||||
s.cfg.window.x = Some(pos.x);
|
||||
s.cfg.window.y = Some(pos.y);
|
||||
s.cfg.clone()
|
||||
};
|
||||
let dir = st.lock().expect("shell state poisoned").data_dir.clone();
|
||||
cfg.save(&dir);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 几何辅助
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Rect {
|
||||
left: i32,
|
||||
top: i32,
|
||||
right: i32,
|
||||
bottom: i32,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
fn width(&self) -> i32 {
|
||||
self.right - self.left
|
||||
}
|
||||
fn height(&self) -> i32 {
|
||||
self.bottom - self.top
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_of(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>) -> Rect {
|
||||
Rect {
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
right: pos.x + size.width as i32,
|
||||
bottom: pos.y + size.height as i32,
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近边与距离 (窗口边缘 ↔ 可工作区边缘)
|
||||
fn nearest_edge(rect: &Rect, wa: &win::WorkArea) -> (Edge, f64) {
|
||||
let candidates = [
|
||||
(Edge::Left, (rect.left - wa.left).abs()),
|
||||
(Edge::Right, (wa.right - rect.right).abs()),
|
||||
(Edge::Top, (rect.top - wa.top).abs()),
|
||||
(Edge::Bottom, (wa.bottom - rect.bottom).abs()),
|
||||
];
|
||||
let (edge, dist) = candidates
|
||||
.into_iter()
|
||||
.min_by_key(|(_, d)| *d)
|
||||
.unwrap_or((Edge::Left, i32::MAX));
|
||||
(edge, dist as f64)
|
||||
}
|
||||
|
||||
/// 触碰判定 (W-SNP-01, 修订决策 #1):
|
||||
/// ① 入带 —— 当前窗口边缘落在边缘条带 [edge, edge+SNAP] 内;
|
||||
/// ② 越线 —— 上一样本在带外, 当前样本已到线或越过线 (快速拖动跨过条带的采样间隙)。
|
||||
/// 对不可见边框 (视觉贴边时 rect 已越界 7~9px) 与过冲免疫;
|
||||
/// 停泊在边缘以外 (如窗口压住任务栏区, rect.bottom > wa.bottom) 不算触碰。
|
||||
fn edge_touching(prev: Option<&Rect>, cur: &Rect, wa: &win::WorkArea) -> Option<Edge> {
|
||||
let s = SNAP_PX as i32;
|
||||
let in_band = |a: i32, lo: i32, hi: i32| a >= lo && a <= hi;
|
||||
let crossed = |p: i32, c: i32, line: i32, outward: bool| {
|
||||
if outward {
|
||||
// Bottom: 带在 [line-8, line], "带外"指 p < line-8
|
||||
p < line - s && c >= line - s
|
||||
} else {
|
||||
// Left/Right/Top: 带在 [line, line+8], "带外"指 p > line+8
|
||||
p > line + s && c <= line + s
|
||||
}
|
||||
};
|
||||
let hit =
|
||||
|p: Option<i32>, c: i32, lo: i32, hi: i32, line: i32, outward: bool| match p {
|
||||
Some(p) => in_band(c, lo, hi) || crossed(p, c, line, outward),
|
||||
None => in_band(c, lo, hi),
|
||||
};
|
||||
if hit(prev.map(|r| r.left), cur.left, wa.left, wa.left + s, wa.left, false) {
|
||||
Some(Edge::Left)
|
||||
} else if hit(prev.map(|r| r.right), cur.right, wa.right - s, wa.right, wa.right, true) {
|
||||
Some(Edge::Right)
|
||||
} else if hit(prev.map(|r| r.top), cur.top, wa.top, wa.top + s, wa.top, false) {
|
||||
Some(Edge::Top)
|
||||
} else if hit(prev.map(|r| r.bottom), cur.bottom, wa.bottom - s, wa.bottom, wa.bottom, true) {
|
||||
Some(Edge::Bottom)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 贴边位置: 保持沿边坐标, 垂直于边的坐标贴到边缘
|
||||
fn flush_pos(rect: &Rect, edge: Edge, wa: &win::WorkArea) -> PhysicalPosition<i32> {
|
||||
match edge {
|
||||
Edge::Left => PhysicalPosition::new(wa.left, wa.clamp_y(rect.top, rect.height())),
|
||||
Edge::Right => PhysicalPosition::new(wa.right - rect.width(), wa.clamp_y(rect.top, rect.height())),
|
||||
Edge::Top => PhysicalPosition::new(wa.clamp_x(rect.left, rect.width()), wa.top),
|
||||
Edge::Bottom => PhysicalPosition::new(wa.clamp_x(rect.left, rect.width()), wa.bottom - rect.height()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 主窗口在给定边/沿边偏移下的贴边位置
|
||||
fn main_flush_from_offset(
|
||||
edge: Edge,
|
||||
offset: i32,
|
||||
size: PhysicalSize<u32>,
|
||||
wa: &win::WorkArea,
|
||||
) -> (i32, i32) {
|
||||
let w = size.width as i32;
|
||||
let h = size.height as i32;
|
||||
match edge {
|
||||
Edge::Left => (wa.left, wa.clamp_y(offset - h / 2, h)),
|
||||
Edge::Right => (wa.right - w, wa.clamp_y(offset - h / 2, h)),
|
||||
Edge::Top => (wa.clamp_x(offset - w / 2, w), wa.top),
|
||||
Edge::Bottom => (wa.clamp_x(offset - w / 2, w), wa.bottom - h),
|
||||
}
|
||||
}
|
||||
|
||||
/// 沿边中心 (Left/Right → 垂直中心; Top/Bottom → 水平中心)
|
||||
fn along_center(rect: &Rect, edge: Edge) -> i32 {
|
||||
match edge {
|
||||
Edge::Left | Edge::Right => rect.top + rect.height() / 2,
|
||||
Edge::Top | Edge::Bottom => rect.left + rect.width() / 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// 悬浮球沿边位置夹取 (完整可见, W-SNP-02)
|
||||
fn clamp_offset(offset: i32, edge: Edge, wa: &win::WorkArea, handle_long: f64, scale: f64) -> i32 {
|
||||
let half = (handle_long * scale / 2.0).round() as i32;
|
||||
match edge {
|
||||
Edge::Left | Edge::Right => offset.clamp(wa.top + half, wa.bottom - half),
|
||||
Edge::Top | Edge::Bottom => offset.clamp(wa.left + half, wa.right - half),
|
||||
}
|
||||
}
|
||||
|
||||
fn tray_icon_image(dark: bool) -> tauri::Result<tauri::image::Image<'static>> {
|
||||
if dark {
|
||||
tauri::image::Image::from_bytes(include_bytes!("../../icons/tray-dark.png"))
|
||||
} else {
|
||||
tauri::image::Image::from_bytes(include_bytes!("../../icons/tray-light.png"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Win32 辅助: 可工作区 / 独占全屏检测 / 系统深浅色 (W-SNP、W-THE)
|
||||
#![cfg(windows)]
|
||||
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{HWND, POINT, RECT},
|
||||
Graphics::Gdi::{
|
||||
GetMonitorInfoW, MonitorFromPoint, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
|
||||
},
|
||||
System::Registry::{RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_DWORD},
|
||||
UI::Shell::{SHQueryUserNotificationState, QUNS_RUNNING_D3D_FULL_SCREEN},
|
||||
UI::WindowsAndMessaging::GetCursorPos,
|
||||
};
|
||||
|
||||
/// 当前光标位置 (物理像素)
|
||||
pub fn cursor_pos() -> Option<(i32, i32)> {
|
||||
let mut pt = POINT { x: 0, y: 0 };
|
||||
unsafe {
|
||||
if GetCursorPos(&mut pt) == 0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((pt.x, pt.y))
|
||||
}
|
||||
|
||||
/// 显示器可工作区 (物理像素, 已扣除任务栏, W-EX-05)
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WorkArea {
|
||||
pub left: i32,
|
||||
pub top: i32,
|
||||
pub right: i32,
|
||||
pub bottom: i32,
|
||||
}
|
||||
|
||||
impl WorkArea {
|
||||
pub fn height(&self) -> i32 {
|
||||
self.bottom - self.top
|
||||
}
|
||||
pub fn clamp_x(&self, x: i32, w: i32) -> i32 {
|
||||
x.clamp(self.left, (self.right - w).max(self.left))
|
||||
}
|
||||
pub fn clamp_y(&self, y: i32, h: i32) -> i32 {
|
||||
y.clamp(self.top, (self.bottom - h).max(self.top))
|
||||
}
|
||||
}
|
||||
|
||||
fn utf16(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// 窗口所在显示器的可工作区 (hwnd: tauri `hwnd().0`, 即裸指针)
|
||||
pub fn workarea_for_hwnd(hwnd: *mut std::ffi::c_void) -> Option<WorkArea> {
|
||||
unsafe {
|
||||
let hmon = MonitorFromWindow(hwnd as HWND, MONITOR_DEFAULTTONEAREST);
|
||||
if hmon.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut info: MONITORINFO = std::mem::zeroed();
|
||||
info.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
|
||||
if GetMonitorInfoW(hmon, &mut info) == 0 {
|
||||
return None;
|
||||
}
|
||||
let RECT { left, top, right, bottom } = info.rcWork;
|
||||
Some(WorkArea { left, top, right, bottom })
|
||||
}
|
||||
}
|
||||
|
||||
/// 前台是否为独占全屏 (D3D FSE, W-SNP-16)。
|
||||
/// 无边框全屏 / 全屏优化不在此列 (悬浮球保持可见, W-SNP-15)。
|
||||
pub fn is_exclusive_fullscreen() -> bool {
|
||||
unsafe {
|
||||
let mut state: i32 = 0;
|
||||
if SHQueryUserNotificationState(&mut state) < 0 {
|
||||
return false; // 判定失败 → 默认非全屏 (W-EX-11)
|
||||
}
|
||||
state == QUNS_RUNNING_D3D_FULL_SCREEN
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统「应用模式」是否为深色 (W-THE-01, AppsUseLightTheme == 0)
|
||||
pub fn system_prefers_dark() -> bool {
|
||||
unsafe {
|
||||
let subkey = utf16("Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize");
|
||||
let value = utf16("AppsUseLightTheme");
|
||||
let mut val: u32 = 1; // 缺省按浅色
|
||||
let mut size = std::mem::size_of::<u32>() as u32;
|
||||
let status = RegGetValueW(
|
||||
HKEY_CURRENT_USER,
|
||||
subkey.as_ptr(),
|
||||
value.as_ptr(),
|
||||
RRF_RT_REG_DWORD,
|
||||
std::ptr::null_mut(),
|
||||
(&mut val as *mut u32).cast(),
|
||||
&mut size,
|
||||
);
|
||||
if status != 0 {
|
||||
return false; // 读不到按浅色 (W-EX-11 口径)
|
||||
}
|
||||
val == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 物理坐标所在显示器的可工作区 (启动位置校验, W-WIN-07)
|
||||
pub fn workarea_for_point(x: i32, y: i32) -> Option<WorkArea> {
|
||||
unsafe {
|
||||
let hmon = MonitorFromPoint(POINT { x, y }, MONITOR_DEFAULTTONEAREST);
|
||||
if hmon.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut info: MONITORINFO = std::mem::zeroed();
|
||||
info.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
|
||||
if GetMonitorInfoW(hmon, &mut info) == 0 {
|
||||
return None;
|
||||
}
|
||||
let RECT { left, top, right, bottom } = info.rcWork;
|
||||
Some(WorkArea { left, top, right, bottom })
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "iBoard",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"identifier": "com.iboard.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"beforeBuildCommand": "npm run build:shell",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"plugins": {
|
||||
"hotswap": {
|
||||
"endpoint": "http://bh.vps.honor3.com:3001/api/ota/check/{{current_sequence}}",
|
||||
"endpoint": "https://iboard.honor3.com/api/ota/check/{{current_sequence}}",
|
||||
"pubkey": "RWQQ4/wfHjJiC/O6NmA7eIQYUNRpCsz5wLLsLqJXgHmsUraAavUWx0HA",
|
||||
"require_https": false,
|
||||
"headers": {}
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "iBoard",
|
||||
"width": 420,
|
||||
"height": 820,
|
||||
"minWidth": 360,
|
||||
"minHeight": 640,
|
||||
"width": 323,
|
||||
"height": 574,
|
||||
"minWidth": 242,
|
||||
"minHeight": 430,
|
||||
"resizable": true,
|
||||
"visible": false,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' data: blob: asset: http://asset.localhost; connect-src 'self' ipc: http://ipc.localhost http://bh.vps.honor3.com:3001 https://bh.vps.honor3.com:3001; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' data: blob: asset: http://asset.localhost; connect-src 'self' ipc: http://ipc.localhost https://iboard.honor3.com http://bh.vps.honor3.com:3001 https://bh.vps.honor3.com:3001; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -43,6 +45,13 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"languages": ["SimpChinese", "English"],
|
||||
"displayLanguageSelector": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"minSdkVersion": 24
|
||||
}
|
||||
|
||||
@@ -21,6 +21,16 @@ initOta()
|
||||
function calibrateSafeTop() {
|
||||
const root = document.documentElement
|
||||
|
||||
// 校准只对 Android 有意义 (状态栏/刘海让位)。桌面 (Windows WebView2 / 桌面浏览器)
|
||||
// 没有状态栏: env() 本来就是 0; 而「screen.height - visualViewport.height」在桌面
|
||||
// 竖屏窗口下会把标题栏+边框高度 (~99px) 误判成状态栏, 让标题栏下多出一条大空隙
|
||||
// (2026-10-26 Windows 外壳实测)。非 Android 一律 0。
|
||||
const isAndroid = /android/i.test(navigator.userAgent)
|
||||
if (!isAndroid) {
|
||||
root.style.setProperty('--safe-top', '0px')
|
||||
return
|
||||
}
|
||||
|
||||
// env() 的真实值必须用一个临时元素测量, 不能直接读 --safe-top ——
|
||||
// 该变量是 max(env(...), 0px) 这样的表达式, parseFloat 会得到 NaN.
|
||||
const probe = document.createElement('div')
|
||||
@@ -37,7 +47,7 @@ function calibrateSafeTop() {
|
||||
const viewH = window.visualViewport?.height || window.innerHeight || 0
|
||||
const screenW = window.screen?.width || 0
|
||||
const viewW = window.visualViewport?.width || window.innerWidth || 0
|
||||
// 只在"纵向且高度差合理"时采用, 避免横屏/桌面窗口误判
|
||||
// 只在"纵向且高度差合理"时采用, 避免横屏误判
|
||||
const isPortrait = viewW <= viewH
|
||||
const diff = screenH - viewH
|
||||
const measured = isPortrait && diff > 0 && diff < 120 ? diff : 0
|
||||
@@ -45,7 +55,7 @@ function calibrateSafeTop() {
|
||||
// 取 env() 与实测值的较大者; 两者都失效时给 24px 保底
|
||||
// (多数 Android 状态栏在 24~28px, 宁可多一点也不能压字)
|
||||
let top = Math.max(envTop, measured)
|
||||
if (top < 4) top = 24 // env 与实测都没拿到 → 用保守兜底, 保证不遮挡
|
||||
if (top < 4) top = 24
|
||||
root.style.setProperty('--safe-top', top + 'px')
|
||||
}
|
||||
|
||||
|
||||