feat(0.2.1): 合并 Windows 外壳 + 离线编辑, 版本 0.2.1, 正式域名 https://iboard.honor3.com

This commit is contained in:
jif2.zhang
2026-10-26 20:53:38 +08:00
parent e689e44923
commit eef296942f
74 changed files with 2734 additions and 437 deletions
+152
View File
@@ -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)
+49
View File
@@ -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)
}
+143 -143
View File
@@ -1,143 +1,143 @@
#!/usr/bin/env bash
# iBoard Android APK 打包
# 用法: bash scripts/wsl/build-apk.sh [debug|release]
# release 模式自动构建 4 个 ABI 并出 universal APK
set -euo pipefail
WIN_PROJECT='/mnt/d/workbench/iboard'
cd "$WIN_PROJECT"
VARIANT="${1:-debug}"
echo "构建变体: $VARIANT"
# 环境变量
export ANDROID_HOME="${ANDROID_HOME:-$HOME/android-sdk}"
export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/27.2.12479018}"
# 关键: 把 NDK toolchain bin 加进 PATH, .cargo/config.toml 里
# linker = "aarch64-linux-android24-clang" 这种短名才能被解析到
NDK_TOOLCHAIN_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
if [ ! -d "$NDK_TOOLCHAIN_BIN" ]; then
echo "ERROR: NDK toolchain bin 不存在: $NDK_TOOLCHAIN_BIN"
echo "请确认 ANDROID_NDK_HOME 正确指向 NDK 27.2.x 目录"
exit 1
fi
export PATH="$NDK_TOOLCHAIN_BIN:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$HOME/.cargo/bin:$PATH"
# 0. 首次需要 android 工程
cd src-tauri
if [ ! -d "gen/android" ]; then
echo "=== 0. 初始化 Android 工程 (一次性) ==="
npx tauri android init
fi
cd "$WIN_PROJECT"
# Android 后端地址, 写到前端 bundle 里 (覆盖默认的 /api 相对路径)
export VITE_API_BASE="${VITE_API_BASE:-http://bh.vps.honor3.com:3001}"
echo "VITE_API_BASE=$VITE_API_BASE"
# 1. 先构建前端, 让 Tauri android 工程能拿到 dist/
echo "=== 1. 构建前端 ==="
npm run build
# 1.5 拷贝 dist/ 到 Android 工程 assets/, 绕过 Tauri CLI 时这步要做
# Tauri CLI 默认在 android build 前会干这事, 我们走直 gradle 要自己补上
ANDROID_ASSETS="$WIN_PROJECT/src-tauri/gen/android/app/src/main/assets"
mkdir -p "$ANDROID_ASSETS"
# 清空旧的 (避免上次的残余)
find "$ANDROID_ASSETS" -mindepth 1 -maxdepth 1 ! -name dexopt ! -name tauri.conf.json -exec rm -rf {} +
cp -r dist/. "$ANDROID_ASSETS/"
echo "前端 bundle 已拷贝到 $ANDROID_ASSETS"
ls -la "$ANDROID_ASSETS" | head -10
# 2. 走不同路径
cd src-tauri
# 全部 4 个 Android 架构的 rustBuild 任务 (Tauri 2 默认会编 4 个)
RUST_BUILD_TASKS=(
":app:rustBuildArm64Release"
":app:rustBuildArmRelease"
":app:rustBuildX86_64Release"
":app:rustBuildX86Release"
)
SKIP_ARGS=()
for t in "${RUST_BUILD_TASKS[@]}"; do SKIP_ARGS+=(-x "${t#:}"); done
# 4 个 Android 目标的 cargo target triple
ANDROID_TARGETS=(
"aarch64-linux-android"
"armv7-linux-androideabi"
"x86_64-linux-android"
"i686-linux-android"
)
if [ "$VARIANT" = "release" ]; then
echo "[release] 手工 cargo 编 lib (4 个 ABI), gradle 只跑 packaging (跳过 rustBuild* 避免 --release 重复 bug)"
for target in "${ANDROID_TARGETS[@]}"; do
echo " [cargo] build lib release for $target ..."
cargo build \
--target "$target" \
--features custom-protocol \
--lib \
--release
done
echo " [gradle] assembleRelease -x rustBuild* ..."
cd gen/android
./gradlew --no-daemon assembleRelease "${SKIP_ARGS[@]}"
APK_DIR="$WIN_PROJECT/src-tauri/gen/android/app/build/outputs/apk/universal/release"
# Gradle 配了 signingConfig 就输出 app-universal-release.apk,
# 否则输出 app-universal-release-unsigned.apk, 按文件名判断是否已签名
SIGNED_APK="$APK_DIR/app-universal-release.apk"
UNSIGNED_APK="$APK_DIR/app-universal-release-unsigned.apk"
if [ -f "$SIGNED_APK" ]; then
APK_PATH="$SIGNED_APK"
APK_STATUS="已签名"
elif [ -f "$UNSIGNED_APK" ]; then
APK_PATH="$UNSIGNED_APK"
APK_STATUS="未签名"
else
APK_PATH=""
APK_STATUS="未找到"
fi
echo
echo "=== 产物 ($APK_STATUS) ==="
if [ -n "$APK_PATH" ]; then
ls -lh "$APK_PATH"
else
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK)"
fi
if [ "$APK_STATUS" = "已签名" ]; then
echo
echo "=== 安装到设备 ==="
echo " adb install -r $APK_PATH"
elif [ "$APK_STATUS" = "未签名" ]; then
echo
echo "=== Release 签名 (按需) ==="
echo "1. 生成 keystore (一次性):"
echo " keytool -genkey -v -keystore ~/iboard-release.keystore -alias iboard -keyalg RSA -keysize 2048 -validity 10000"
echo "2. 在 ~/.gradle/gradle.properties 写:"
echo " IBOARD_UPLOAD_STORE_FILE=/home/$(whoami)/iboard-release.keystore"
echo " IBOARD_UPLOAD_KEY_ALIAS=iboard"
echo " IBOARD_UPLOAD_STORE_PASSWORD=..."
echo " IBOARD_UPLOAD_KEY_PASSWORD=..."
echo "3. 重跑本脚本 (Gradle 读 gradle.properties 里的 IBOARD_UPLOAD_* 自动用 releaseSigning)"
echo
echo "=== 临时用 debug keystore 手动签 (仅测试) ==="
echo " \$ANDROID_HOME/build-tools/*/apksigner sign --ks ~/.android/debug.keystore \"
echo " --ks-pass pass:android --key-pass pass:android \"
echo " $APK_PATH"
fi
else
echo "[debug] Tauri CLI debug 路径 (不传 --release 不会触发 bug), 直接走"
npx tauri android build --apk
APK_DIR="$WIN_PROJECT/src-tauri/target/debug/bundle/android/app/build/outputs/apk/debug"
echo
echo "=== 产物 ==="
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK)"
fi
#!/usr/bin/env bash
# iBoard Android APK 打包
# 用法: bash scripts/wsl/build-apk.sh [debug|release]
# release 模式自动构建 4 个 ABI 并出 universal APK
set -euo pipefail
WIN_PROJECT='/mnt/d/workbench/iboard'
cd "$WIN_PROJECT"
VARIANT="${1:-debug}"
echo "构建变体: $VARIANT"
# 环境变量
export ANDROID_HOME="${ANDROID_HOME:-$HOME/android-sdk}"
export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/27.2.12479018}"
# 关键: 把 NDK toolchain bin 加进 PATH, .cargo/config.toml 里
# linker = "aarch64-linux-android24-clang" 这种短名才能被解析到
NDK_TOOLCHAIN_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
if [ ! -d "$NDK_TOOLCHAIN_BIN" ]; then
echo "ERROR: NDK toolchain bin 不存在: $NDK_TOOLCHAIN_BIN"
echo "请确认 ANDROID_NDK_HOME 正确指向 NDK 27.2.x 目录"
exit 1
fi
export PATH="$NDK_TOOLCHAIN_BIN:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$HOME/.cargo/bin:$PATH"
# 0. 首次需要 android 工程
cd src-tauri
if [ ! -d "gen/android" ]; then
echo "=== 0. 初始化 Android 工程 (一次性) ==="
npx tauri android init
fi
cd "$WIN_PROJECT"
# Android 后端地址, 写到前端 bundle 里 (覆盖默认的 /api 相对路径)
export VITE_API_BASE="${VITE_API_BASE:-https://iboard.honor3.com}"
echo "VITE_API_BASE=$VITE_API_BASE"
# 1. 先构建前端, 让 Tauri android 工程能拿到 dist/
echo "=== 1. 构建前端 ==="
npm run build
# 1.5 拷贝 dist/ 到 Android 工程 assets/, 绕过 Tauri CLI 时这步要做
# Tauri CLI 默认在 android build 前会干这事, 我们走直 gradle 要自己补上
ANDROID_ASSETS="$WIN_PROJECT/src-tauri/gen/android/app/src/main/assets"
mkdir -p "$ANDROID_ASSETS"
# 清空旧的 (避免上次的残余)
find "$ANDROID_ASSETS" -mindepth 1 -maxdepth 1 ! -name dexopt ! -name tauri.conf.json -exec rm -rf {} +
cp -r dist/. "$ANDROID_ASSETS/"
echo "前端 bundle 已拷贝到 $ANDROID_ASSETS"
ls -la "$ANDROID_ASSETS" | head -10
# 2. 走不同路径
cd src-tauri
# 全部 4 个 Android 架构的 rustBuild 任务 (Tauri 2 默认会编 4 个)
RUST_BUILD_TASKS=(
":app:rustBuildArm64Release"
":app:rustBuildArmRelease"
":app:rustBuildX86_64Release"
":app:rustBuildX86Release"
)
SKIP_ARGS=()
for t in "${RUST_BUILD_TASKS[@]}"; do SKIP_ARGS+=(-x "${t#:}"); done
# 4 个 Android 目标的 cargo target triple
ANDROID_TARGETS=(
"aarch64-linux-android"
"armv7-linux-androideabi"
"x86_64-linux-android"
"i686-linux-android"
)
if [ "$VARIANT" = "release" ]; then
echo "[release] 手工 cargo 编 lib (4 个 ABI), gradle 只跑 packaging (跳过 rustBuild* 避免 --release 重复 bug)"
for target in "${ANDROID_TARGETS[@]}"; do
echo " [cargo] build lib release for $target ..."
cargo build \
--target "$target" \
--features custom-protocol \
--lib \
--release
done
echo " [gradle] assembleRelease -x rustBuild* ..."
cd gen/android
./gradlew --no-daemon assembleRelease "${SKIP_ARGS[@]}"
APK_DIR="$WIN_PROJECT/src-tauri/gen/android/app/build/outputs/apk/universal/release"
# Gradle 配了 signingConfig 就输出 app-universal-release.apk,
# 否则输出 app-universal-release-unsigned.apk, 按文件名判断是否已签名
SIGNED_APK="$APK_DIR/app-universal-release.apk"
UNSIGNED_APK="$APK_DIR/app-universal-release-unsigned.apk"
if [ -f "$SIGNED_APK" ]; then
APK_PATH="$SIGNED_APK"
APK_STATUS="已签名"
elif [ -f "$UNSIGNED_APK" ]; then
APK_PATH="$UNSIGNED_APK"
APK_STATUS="未签名"
else
APK_PATH=""
APK_STATUS="未找到"
fi
echo
echo "=== 产物 ($APK_STATUS) ==="
if [ -n "$APK_PATH" ]; then
ls -lh "$APK_PATH"
else
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK)"
fi
if [ "$APK_STATUS" = "已签名" ]; then
echo
echo "=== 安装到设备 ==="
echo " adb install -r $APK_PATH"
elif [ "$APK_STATUS" = "未签名" ]; then
echo
echo "=== Release 签名 (按需) ==="
echo "1. 生成 keystore (一次性):"
echo " keytool -genkey -v -keystore ~/iboard-release.keystore -alias iboard -keyalg RSA -keysize 2048 -validity 10000"
echo "2. 在 ~/.gradle/gradle.properties 写:"
echo " IBOARD_UPLOAD_STORE_FILE=/home/$(whoami)/iboard-release.keystore"
echo " IBOARD_UPLOAD_KEY_ALIAS=iboard"
echo " IBOARD_UPLOAD_STORE_PASSWORD=..."
echo " IBOARD_UPLOAD_KEY_PASSWORD=..."
echo "3. 重跑本脚本 (Gradle 读 gradle.properties 里的 IBOARD_UPLOAD_* 自动用 releaseSigning)"
echo
echo "=== 临时用 debug keystore 手动签 (仅测试) ==="
echo " \$ANDROID_HOME/build-tools/*/apksigner sign --ks ~/.android/debug.keystore \"
echo " --ks-pass pass:android --key-pass pass:android \"
echo " $APK_PATH"
fi
else
echo "[debug] Tauri CLI debug 路径 (不传 --release 不会触发 bug), 直接走"
npx tauri android build --apk
APK_DIR="$WIN_PROJECT/src-tauri/target/debug/bundle/android/app/build/outputs/apk/debug"
echo
echo "=== 产物 ==="
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK)"
fi