feat(updater): 增加应用自动更新入口 (#84)

H-Chris233: closes #33。

实现 Tauri 内置 updater 集成:
- Cargo: tauri-plugin-updater v2
- tauri.conf.json: 加 updater 配置(pubkey + endpoint 指向 GitHub Releases latest)
- capabilities/default.json: 授予 updater:default 能力
- lib.rs: 注册 updater 插件 + restart_app 命令
- src/pages/Settings.tsx + i18n: 关于区域加'检查'按钮、检查/下载/安装/重启对话框流
- 新脚本 scripts/write-updater-manifest.mjs: release 侧从 build artifact 拉签名生成 manifest
- workflow: TAURI_SIGNING_PRIVATE_KEY env + 各平台 build 时 createUpdaterArtifacts true

注意:下次 v*-tauri tag 必须配 TAURI_SIGNING_PRIVATE_KEY secret,否则 CI 硬失败。
This commit is contained in:
baiqing committed 2026-04-30 17:20:32 +08:00
1 parent 786bc574a7
commit a9c81e6e0e
16 files changed
+695 -31

No files matched your search

+91 -2
View File
@@ -30,11 +30,20 @@ jobs:
include:
- platform: macos-latest
rust-target: aarch64-apple-darwin
updater-target: darwin
updater-arch: aarch64
- platform: windows-latest
rust-target: x86_64-pc-windows-msvc
updater-target: windows
updater-arch: x86_64
- platform: ubuntu-22.04
rust-target: x86_64-unknown-linux-gnu
updater-target: linux
updater-arch: x86_64
runs-on: ${{ matrix.platform }}
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
steps:
- uses: actions/checkout@v4
@@ -76,6 +85,17 @@ jobs:
working-directory: 'openless-all/app'
run: npm ci
- name: Check updater signing availability
if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri')
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "::error::TAURI_SIGNING_PRIVATE_KEY is required for signed auto-update artifacts."
exit 1
fi
- name: Check Apple signing availability
if: matrix.platform == 'macos-latest' && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri')
shell: bash
@@ -159,19 +179,50 @@ jobs:
working-directory: 'openless-all/app'
env:
INSTALL: '0' # CI 不要装到 /Applications,也不要 reset TCC
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: bash scripts/build-mac.sh
# ── Windows:仅 Windows 跑通用 tauri build(Linux 用下方专用 step,避免重复构建)──
- name: Build (Windows)
if: matrix.platform == 'windows-latest'
shell: bash
working-directory: 'openless-all/app'
run: npm run tauri build
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
npm run tauri -- build --config '{"bundle":{"createUpdaterArtifacts":true}}'
else
npm run tauri build
fi
# ── Linux:产 deb / rpm / AppImage ──
- name: Build (Linux)
if: matrix.platform == 'ubuntu-22.04'
shell: bash
working-directory: 'openless-all/app'
run: npm run tauri -- build --bundles deb,rpm,appimage
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
npm run tauri -- build --bundles deb,rpm,appimage --config '{"bundle":{"createUpdaterArtifacts":true}}'
else
npm run tauri -- build --bundles deb,rpm,appimage
fi
- name: Write updater manifest
if: env.TAURI_SIGNING_PRIVATE_KEY != ''
shell: bash
working-directory: 'openless-all/app'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
OPENLESS_UPDATE_TARGET: ${{ matrix.updater-target }}
OPENLESS_UPDATE_ARCH: ${{ matrix.updater-arch }}
OPENLESS_UPDATE_REPO: appergb/openless
run: node scripts/write-updater-manifest.mjs
# ── 收集产物 ──
- name: List artifacts (debug)
@@ -206,6 +257,17 @@ jobs:
openless-all/app/src-tauri/target/release/bundle/dmg/*.dmg
if-no-files-found: error
- name: Upload macOS updater artifacts
if: matrix.platform == 'macos-latest' && env.TAURI_SIGNING_PRIVATE_KEY != ''
uses: actions/upload-artifact@v4
with:
name: openless-macos-arm64-updater
path: |
openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz
openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz.sig
openless-all/app/src-tauri/target/release/bundle/latest-darwin-aarch64.json
if-no-files-found: error
- name: Upload Windows artifacts
if: matrix.platform == 'windows-latest'
uses: actions/upload-artifact@v4
@@ -216,6 +278,17 @@ jobs:
openless-all/app/src-tauri/target/release/bundle/msi/*.msi
if-no-files-found: error
- name: Upload Windows updater artifacts
if: matrix.platform == 'windows-latest' && env.TAURI_SIGNING_PRIVATE_KEY != ''
uses: actions/upload-artifact@v4
with:
name: openless-windows-x64-updater
path: |
openless-all/app/src-tauri/target/release/bundle/nsis/*.exe.sig
openless-all/app/src-tauri/target/release/bundle/msi/*.msi.sig
openless-all/app/src-tauri/target/release/bundle/latest-windows-x86_64.json
if-no-files-found: error
- name: Upload Linux artifacts
if: matrix.platform == 'ubuntu-22.04'
uses: actions/upload-artifact@v4
@@ -227,6 +300,16 @@ jobs:
openless-all/app/src-tauri/target/release/bundle/appimage/*.AppImage
if-no-files-found: error
- name: Upload Linux updater artifacts
if: matrix.platform == 'ubuntu-22.04' && env.TAURI_SIGNING_PRIVATE_KEY != ''
uses: actions/upload-artifact@v4
with:
name: openless-linux-x64-updater
path: |
openless-all/app/src-tauri/target/release/bundle/appimage/*.AppImage.sig
openless-all/app/src-tauri/target/release/bundle/latest-linux-x86_64.json
if-no-files-found: error
# ── tag 推送时,同步上传到 GitHub Release ──
- name: Create / update release
if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri')
@@ -239,8 +322,14 @@ jobs:
generate_release_notes: true
files: |
openless-all/app/src-tauri/target/release/bundle/dmg/*.dmg
openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz
openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz.sig
openless-all/app/src-tauri/target/release/bundle/nsis/*.exe
openless-all/app/src-tauri/target/release/bundle/nsis/*.exe.sig
openless-all/app/src-tauri/target/release/bundle/msi/*.msi
openless-all/app/src-tauri/target/release/bundle/msi/*.msi.sig
openless-all/app/src-tauri/target/release/bundle/deb/*.deb
openless-all/app/src-tauri/target/release/bundle/rpm/*.rpm
openless-all/app/src-tauri/target/release/bundle/appimage/*.AppImage
openless-all/app/src-tauri/target/release/bundle/appimage/*.AppImage.sig
openless-all/app/src-tauri/target/release/bundle/latest-*.json
+12 -2
View File
@@ -1,15 +1,16 @@
{
"name": "openless-app",
"version": "1.1.4",
"version": "1.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openless-app",
"version": "1.1.4",
"version": "1.2.2",
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-shell": "^2.0.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"i18next": "^26.0.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -1349,6 +1350,15 @@
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+1
View File
@@ -13,6 +13,7 @@
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-shell": "^2.0.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"i18next": "^26.0.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+5 -1
View File
@@ -25,7 +25,11 @@ else
fi
echo "▶ tauri build"
npm run tauri build
TAURI_BUILD_ARGS=(build)
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ -n "${TAURI_SIGNING_PRIVATE_KEY_PATH:-}" ]; then
TAURI_BUILD_ARGS+=(--config '{"bundle":{"createUpdaterArtifacts":true}}')
fi
npm run tauri -- "${TAURI_BUILD_ARGS[@]}"
echo "▶ 校验 Info.plist / 签名"
/usr/libexec/PlistBuddy -c "Print :NSMicrophoneUsageDescription" "$INFO" >/dev/null
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { basename, join } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const target = process.env.OPENLESS_UPDATE_TARGET;
const arch = process.env.OPENLESS_UPDATE_ARCH;
const repo = process.env.OPENLESS_UPDATE_REPO || 'appergb/openless';
if (!target || !arch) {
throw new Error('OPENLESS_UPDATE_TARGET and OPENLESS_UPDATE_ARCH are required');
}
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
const bundleDir = fileURLToPath(new URL('../src-tauri/target/release/bundle/', import.meta.url));
const candidatesByTarget = {
darwin: ['macos/OpenLess.app.tar.gz'],
windows: ['nsis/OpenLess_*_x64-setup.exe', 'nsis/OpenLess*_x64-setup.exe'],
linux: ['appimage/OpenLess_*.AppImage', 'appimage/OpenLess*.AppImage'],
};
function findFirst(patterns) {
for (const pattern of patterns) {
if (!pattern.includes('*')) {
const path = join(bundleDir, pattern);
if (existsSync(path)) return path;
continue;
}
const [dir, namePattern] = pattern.split('/');
const dirPath = join(bundleDir, dir);
if (!existsSync(dirPath)) continue;
const prefix = namePattern.split('*')[0];
const suffix = namePattern.split('*').at(-1);
const match = readdirSync(dirPath)
.filter(name => name.startsWith(prefix) && name.endsWith(suffix))
.sort()[0];
if (match) return join(dirPath, match);
}
}
const artifact = findFirst(candidatesByTarget[target] || []);
if (!artifact) {
throw new Error(`No updater artifact found for ${target} in ${bundleDir}`);
}
const signaturePath = `${artifact}.sig`;
if (!existsSync(signaturePath)) {
throw new Error(`Missing updater signature: ${signaturePath}`);
}
const assetName = basename(artifact);
const manifestName = `latest-${target}-${arch}.json`;
const manifest = {
version: packageJson.version,
pub_date: new Date().toISOString(),
url: `https://github.com/${repo}/releases/latest/download/${assetName}`,
signature: readFileSync(signaturePath, 'utf8').trim(),
};
writeFileSync(join(bundleDir, manifestName), `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`Wrote ${manifestName} for ${assetName}`);
+245 -9
View File
@@ -119,6 +119,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
@@ -858,7 +867,7 @@ dependencies = [
"core-foundation-sys 0.8.7",
"coreaudio-rs",
"dasp_sample",
"jni",
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
@@ -1026,6 +1035,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -1375,6 +1395,17 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2397,6 +2428,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.117",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -2560,7 +2621,10 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
"bitflags 2.11.1",
"libc",
"plain",
"redox_syscall 0.7.4",
]
[[package]]
@@ -2708,6 +2772,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3040,6 +3110,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.1",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
"objc2-core-foundation",
]
@@ -3067,6 +3138,18 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.1",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-quartz-core"
version = "0.2.2"
@@ -3124,7 +3207,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
"jni",
"jni 0.21.1",
"ndk 0.8.0",
"ndk-context",
"num-derive",
@@ -3196,6 +3279,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"thiserror 1.0.69",
"tokio",
"tokio-tungstenite",
@@ -3237,6 +3321,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2 0.6.4",
"objc2-foundation 0.3.2",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -3286,7 +3384,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link 0.2.1",
]
@@ -3513,6 +3611,12 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.9.0"
@@ -3927,6 +4031,15 @@ dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "redox_syscall"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -4041,15 +4154,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -4139,6 +4257,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys 0.8.7",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@@ -4562,6 +4707,22 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_cesu8"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simplelog"
version = "0.12.2"
@@ -4622,7 +4783,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"raw-window-handle",
"redox_syscall",
"redox_syscall 0.5.18",
"tracing",
"wasm-bindgen",
"web-sys",
@@ -4805,7 +4966,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk 0.9.0",
@@ -4837,6 +4998,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tar"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4860,7 +5032,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -5010,6 +5182,39 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.3",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.10.1"
@@ -5020,7 +5225,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2 0.6.4",
"objc2-ui-kit",
"objc2-web-kit",
@@ -5043,7 +5248,7 @@ checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
@@ -5984,6 +6189,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
@@ -6773,7 +6987,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk 0.9.0",
"objc2 0.6.4",
@@ -6837,6 +7051,16 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "xkbcommon"
version = "0.7.0"
@@ -7018,6 +7242,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
+1
View File
@@ -16,6 +16,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "tray-icon"] }
tauri-plugin-shell = "2"
tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -15,6 +15,7 @@
"core:window:allow-close",
"core:webview:default",
"core:event:default",
"shell:allow-open"
"shell:allow-open",
"updater:default"
]
}
+7
View File
@@ -46,6 +46,7 @@ pub fn run() {
show_main_window(app);
}))
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(coordinator.clone())
.setup(move |app| {
// Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。
@@ -165,6 +166,7 @@ pub fn run() {
commands::read_credential,
commands::set_active_asr_provider,
commands::set_active_llm_provider,
restart_app,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
@@ -187,6 +189,11 @@ pub fn run() {
});
}
#[tauri::command]
fn restart_app(app: AppHandle) {
app.restart();
}
/// 把日志同时写到 stderr + ~/Library/Logs/OpenLess/openless.log(match Swift `Log.swift`)。
fn init_file_logger() {
use simplelog::{
@@ -65,5 +65,13 @@
"infoPlist": "Info.plist",
"entitlements": "Entitlements.plist"
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFERUFBODAzNTY0QzMyM0YKUldRL01reFdBNmpxSGE1K0JadlpONXNWTzhJcGZCRGxjUVdIWExNNFJpeUNsSGZwazdlQThhemkK",
"endpoints": [
"https://github.com/appergb/openless/releases/latest/download/latest-{{target}}-{{arch}}.json"
]
}
}
}
@@ -7,8 +7,7 @@
import { useEffect, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from './Icon';
import { APP_VERSION_LABEL } from '../lib/appVersion';
import { Settings as SettingsContent, type SettingsSectionId } from '../pages/Settings';
import { AboutUpdateControl, Settings as SettingsContent, type SettingsSectionId } from '../pages/Settings';
import { Row } from './ui/Row';
import { openExternal } from '../lib/ipc';
import {
@@ -219,17 +218,9 @@ function AboutMini() {
<img src="AppIcon.png" alt="" style={{ width: 56, height: 56, borderRadius: 13, boxShadow: '0 4px 10px rgba(0,0,0,.10), 0 0 0 0.5px rgba(0,0,0,.06)' }} />
<div>
<div style={{ fontSize: 17, fontWeight: 600 }}>OpenLess</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>{t('modal.about.tagline')} · {APP_VERSION_LABEL}</div>
<AboutUpdateControl tagline={t('modal.about.tagline')} />
</div>
</div>
<Row label={t('modal.about.checkUpdate')}>
<button
style={btnGhost}
onClick={() => openExternal('https://github.com/appergb/openless/releases')}
>
{t('modal.about.checkUpdateBtn')}
</button>
</Row>
<Row label={t('modal.about.docs')}>
<button
style={btnGhost}
+30
View File
@@ -15,6 +15,7 @@ export const en: typeof zhCN = {
copy: 'Copy',
delete: 'Delete',
later: 'Later',
cancel: 'Cancel',
close: 'Close',
show: 'Show',
hide: 'Hide',
@@ -243,6 +244,10 @@ export const en: typeof zhCN = {
about: {
tagline: 'Speak naturally, write perfectly',
checkUpdate: 'Check for updates',
checkUpdateBtn: 'Check',
checkingUpdate: 'Checking…',
upToDate: 'You are already on the latest version.',
updateError: 'Update check or install failed. Please try again later.',
openReleases: 'Open Releases',
source: 'Source',
docs: 'Docs',
@@ -253,6 +258,31 @@ export const en: typeof zhCN = {
privacy: 'Privacy',
privacyDesc: 'All transcripts stay on this device. Cloud APIs are only called for real-time transcription/polish; no recordings are retained.',
localFirst: 'Local-first',
updateDialog: {
available: {
title: 'Update available',
desc: 'OpenLess {{version}} is available. Update now?',
},
downloading: {
title: 'Downloading update',
desc: 'Downloading OpenLess {{version}}. Keep the app open.',
},
downloaded: {
title: 'Update ready',
desc: 'OpenLess {{version}} has been installed. Restart automatically now to apply it?',
},
installing: {
title: 'Installing update',
desc: 'Installing OpenLess {{version}}. Keep the app open.',
},
install: 'Update now',
downloadingLabel: 'Downloading…',
installingLabel: 'Installing…',
later: 'Restart manually later',
restartNow: 'Restart now',
progress: '{{progress}}% · {{downloaded}} / {{total}}',
progressUnknown: '{{downloaded}} downloaded',
},
},
},
modal: {
+30
View File
@@ -13,6 +13,7 @@ export const zhCN = {
copy: '复制',
delete: '删除',
later: '稍后',
cancel: '取消',
close: '关闭',
show: '显示',
hide: '隐藏',
@@ -241,6 +242,10 @@ export const zhCN = {
about: {
tagline: '自然说话,完美书写',
checkUpdate: '检查更新',
checkUpdateBtn: '检查',
checkingUpdate: '检查中…',
upToDate: '当前已是最新版本。',
updateError: '检查或更新失败,请稍后重试。',
openReleases: '打开 Releases',
source: '源码',
docs: '文档',
@@ -251,6 +256,31 @@ export const zhCN = {
privacy: '隐私',
privacyDesc: '所有识别结果仅保存在本机。云端 API 仅用于实时转写与润色,不会保留你的录音。',
localFirst: '本地优先',
updateDialog: {
available: {
title: '发现新版本',
desc: '发现 OpenLess {{version}},是否现在更新?',
},
downloading: {
title: '正在下载更新',
desc: '正在下载 OpenLess {{version}},请保持应用打开。',
},
downloaded: {
title: '更新已准备好',
desc: 'OpenLess {{version}} 已安装完成。是否现在自动重启以应用更新?',
},
installing: {
title: '正在安装更新',
desc: '正在安装 OpenLess {{version}},请保持应用打开。',
},
install: '现在更新',
downloadingLabel: '下载中…',
installingLabel: '安装中…',
later: '稍后手动重启',
restartNow: '现在重启',
progress: '{{progress}}% · {{downloaded}} / {{total}}',
progressUnknown: '已下载 {{downloaded}}',
},
},
},
modal: {
+4
View File
@@ -216,6 +216,10 @@ export function triggerMicrophonePrompt(): Promise<void> {
return invokeOrMock('trigger_microphone_prompt', undefined, () => undefined);
}
export function restartApp(): Promise<void> {
return invokeOrMock('restart_app', undefined, () => undefined);
}
export async function openExternal(url: string): Promise<void> {
if (!isTauri) {
window.open(url, '_blank', 'noopener,noreferrer');
+188 -2
View File
@@ -3,6 +3,7 @@
// keep their inline-style literals 1:1 with the source JSX.
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import type { Update, DownloadEvent } from '@tauri-apps/plugin-updater';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import { APP_VERSION_LABEL } from '../lib/appVersion';
@@ -11,11 +12,13 @@ import {
checkAccessibilityPermission,
checkMicrophonePermission,
getHotkeyStatus,
isTauri,
openExternal,
openSystemSettings,
readCredential,
requestAccessibilityPermission,
requestMicrophonePermission,
restartApp,
setActiveAsrProvider,
setActiveLlmProvider,
setCredential,
@@ -752,10 +755,9 @@ function AboutSection() {
>OL</div>
<div>
<div style={{ fontSize: 16, fontWeight: 600 }}>OpenLess</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>{t('settings.about.tagline')} · {APP_VERSION_LABEL}</div>
<AboutUpdateControl tagline={t('settings.about.tagline')} />
</div>
</div>
<SettingRow label={t('settings.about.checkUpdate')}><Btn variant="ghost" size="sm" onClick={() => openExternal('https://github.com/appergb/openless/releases')}>{t('settings.about.openReleases')}</Btn></SettingRow>
<SettingRow label={t('settings.about.source')}><Btn variant="ghost" size="sm" icon="link" onClick={() => openExternal('https://github.com/appergb/openless')}>GitHub</Btn></SettingRow>
<SettingRow label={t('settings.about.docs')}><Btn variant="ghost" size="sm" icon="link" onClick={() => openExternal('https://github.com/appergb/openless#readme')}>README</Btn></SettingRow>
<SettingRow label={t('settings.about.feedback')}><Btn variant="ghost" size="sm" icon="link" onClick={() => openExternal('https://github.com/appergb/openless/issues')}>GitHub Issues</Btn></SettingRow>
@@ -781,6 +783,190 @@ function AboutSection() {
);
}
type UpdateStatus = 'idle' | 'checking' | 'available' | 'none' | 'downloading' | 'installing' | 'downloaded' | 'error';
export function AboutUpdateControl({ tagline }: { tagline: string }) {
const { t } = useTranslation();
const updateRef = useRef<Update | null>(null);
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
const [version, setVersion] = useState('');
const [downloaded, setDownloaded] = useState(0);
const [contentLength, setContentLength] = useState<number | null>(null);
const checking = updateStatus === 'checking';
const busy = updateStatus === 'downloading' || updateStatus === 'installing';
const progress = contentLength && contentLength > 0 ? Math.min(100, Math.round((downloaded / contentLength) * 100)) : null;
const closeUpdate = async () => {
const current = updateRef.current;
updateRef.current = null;
if (current) {
try {
await current.close();
} catch (error) {
console.warn('[settings] failed to close update resource', error);
}
}
};
useEffect(() => {
return () => {
void closeUpdate();
};
}, []);
const resetProgress = () => {
setDownloaded(0);
setContentLength(null);
};
const checkForUpdates = async () => {
setUpdateStatus('checking');
setVersion('');
resetProgress();
await closeUpdate();
try {
if (!isTauri) {
setUpdateStatus('none');
return;
}
const { check } = await import('@tauri-apps/plugin-updater');
const next = await check();
updateRef.current = next;
setVersion(next?.version ?? '');
setUpdateStatus(next ? 'available' : 'none');
} catch (error) {
console.error('[settings] failed to check update', error);
setUpdateStatus('error');
}
};
const installUpdate = async () => {
const update = updateRef.current;
if (!update) return;
resetProgress();
setUpdateStatus('downloading');
try {
await update.download((event: DownloadEvent) => {
if (event.event === 'Started') {
resetProgress();
setContentLength(event.data.contentLength ?? null);
} else if (event.event === 'Progress') {
setDownloaded(value => value + event.data.chunkLength);
} else if (event.event === 'Finished') {
setUpdateStatus('installing');
}
});
setUpdateStatus('installing');
await update.install();
await closeUpdate();
setUpdateStatus('downloaded');
} catch (error) {
console.error('[settings] failed to install update', error);
await closeUpdate();
setUpdateStatus('error');
}
};
const dismissUpdateDialog = async () => {
if (busy) return;
await closeUpdate();
setUpdateStatus('idle');
setVersion('');
resetProgress();
};
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2 }}>
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>{tagline} · {APP_VERSION_LABEL}</span>
<Btn variant="ghost" size="sm" onClick={checkForUpdates} disabled={checking || busy}>
{checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')}
</Btn>
</div>
{(updateStatus === 'none' || updateStatus === 'error') && (
<div style={{ fontSize: 11, color: updateStatus === 'error' ? 'var(--ol-err)' : 'var(--ol-ink-4)', marginTop: 4 }}>
{updateStatus === 'none' ? t('settings.about.upToDate') : t('settings.about.updateError')}
</div>
)}
{(updateStatus === 'available' || updateStatus === 'downloading' || updateStatus === 'installing' || updateStatus === 'downloaded') && (
<UpdateDialog
status={updateStatus}
version={version}
progress={progress}
downloaded={downloaded}
contentLength={contentLength}
onInstall={installUpdate}
onRestart={restartApp}
onClose={dismissUpdateDialog}
/>
)}
</>
);
}
function UpdateDialog({
status,
version,
progress,
downloaded,
contentLength,
onInstall,
onRestart,
onClose,
}: {
status: 'available' | 'downloading' | 'installing' | 'downloaded';
version: string;
progress: number | null;
downloaded: number;
contentLength: number | null;
onInstall: () => void;
onRestart: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const downloading = status === 'downloading';
const installing = status === 'installing';
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.18)', display: 'grid', placeItems: 'center', zIndex: 40 }}>
<div style={{ width: 360, borderRadius: 16, background: 'var(--ol-surface)', border: '0.5px solid var(--ol-line-strong)', boxShadow: '0 18px 42px rgba(0,0,0,0.18)', padding: 18 }}>
<div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>{t(`settings.about.updateDialog.${status}.title`)}</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6, marginBottom: 14 }}>
{t(`settings.about.updateDialog.${status}.desc`, { version })}
</div>
{(downloading || installing || status === 'downloaded') && (
<div style={{ marginBottom: 14 }}>
<div style={{ height: 8, borderRadius: 999, background: 'var(--ol-surface-2)', overflow: 'hidden', border: '0.5px solid var(--ol-line)' }}>
<div style={{ height: '100%', width: `${status === 'downloaded' || installing ? 100 : progress ?? 8}%`, background: 'var(--ol-blue)', transition: 'width 0.18s ease-out' }} />
</div>
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--ol-ink-4)' }}>
{installing
? t('settings.about.updateDialog.installingLabel')
: progress === null
? t('settings.about.updateDialog.progressUnknown', { downloaded: formatBytes(downloaded) })
: t('settings.about.updateDialog.progress', { progress, downloaded: formatBytes(downloaded), total: formatBytes(contentLength ?? 0) })}
</div>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
{status === 'available' && <Btn size="sm" onClick={onClose}>{t('common.cancel')}</Btn>}
{status === 'available' && <Btn variant="blue" size="sm" onClick={onInstall}>{t('settings.about.updateDialog.install')}</Btn>}
{(downloading || installing) && <Btn size="sm" disabled>{installing ? t('settings.about.updateDialog.installingLabel') : t('settings.about.updateDialog.downloadingLabel')}</Btn>}
{status === 'downloaded' && <Btn size="sm" onClick={onClose}>{t('settings.about.updateDialog.later')}</Btn>}
{status === 'downloaded' && <Btn variant="blue" size="sm" onClick={onRestart}>{t('settings.about.updateDialog.restartNow')}</Btn>}
</div>
</div>
</div>
);
}
function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function HotkeyStatusPill({ status }: { status: HotkeyStatus | null }) {
const { t } = useTranslation();
if (!status) {
+6 -3
View File
@@ -103,9 +103,10 @@ interface BtnProps {
icon?: string;
style?: CSSProperties;
onClick?: () => void;
disabled?: boolean;
}
export function Btn({ children, variant = 'ghost', size = 'md', icon, style, onClick }: BtnProps) {
export function Btn({ children, variant = 'ghost', size = 'md', icon, style, onClick, disabled = false }: BtnProps) {
const variants: Record<BtnVariant, { bg: string; color: string; bd: string; sh: string }> = {
primary: { bg: 'var(--ol-ink)', color: '#fff', bd: 'transparent', sh: '0 1px 2px rgba(0,0,0,.08)' },
blue: { bg: 'var(--ol-blue)', color: '#fff', bd: 'transparent', sh: '0 1px 2px rgba(37,99,235,.18)' },
@@ -119,7 +120,8 @@ export function Btn({ children, variant = 'ghost', size = 'md', icon, style, onC
};
return (
<button
onClick={onClick}
onClick={disabled ? undefined : onClick}
disabled={disabled}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
background: v.bg, color: v.color,
@@ -127,7 +129,8 @@ export function Btn({ children, variant = 'ghost', size = 'md', icon, style, onC
borderRadius: 8,
boxShadow: v.sh,
fontFamily: 'inherit', fontWeight: 500,
cursor: 'default',
cursor: disabled ? 'not-allowed' : 'default',
opacity: disabled ? 0.55 : 1,
transition: 'background 0.12s ease-out, color 0.12s ease-out, border-color 0.12s ease-out, box-shadow 0.12s ease-out, transform 0.08s ease-out',
...sizes[size],
...style,