diff --git a/Cargo.lock b/Cargo.lock
index fda857c960..9f7cca751c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5973,6 +5973,7 @@ dependencies = [
"libc",
"log",
"mime_guess",
+ "minisign-verify",
"notify-rust",
"objc2 0.6.4",
"objc2-app-kit",
diff --git a/Cargo.toml b/Cargo.toml
index 43fc2eec80..18eaa6b17c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -248,6 +248,7 @@ unic-langid = "0.9"
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
aes-gcm = "0.10"
sha2 = "0.10"
+minisign-verify = "0.2"
sha1 = "0.10"
argon2 = "0.5"
rand = "0.8"
diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md
index a63decab54..ce45d2818d 100644
--- a/docs/interactive-capabilities/README.md
+++ b/docs/interactive-capabilities/README.md
@@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a
- Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json`
- Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json`
-说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **662** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。
+说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **665** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。
-Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **662** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes.
+Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **665** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes.
## 控制边界 / Control boundary
diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json
index 448472ae95..a635ba4c23 100644
--- a/docs/interactive-capabilities/capabilities.json
+++ b/docs/interactive-capabilities/capabilities.json
@@ -4,7 +4,7 @@
"title": "OpenBitFun Playbook",
"origin": "https://playbook.openbitfun.com",
"source": "src/shared/interactive-capabilities/catalog.json",
- "digest": "c21d28e130de07890a2ebd6793cf78905c7749f577d8b8e547d9fe12a6e26a95",
+ "digest": "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3",
"ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54",
"searchAcceptance": [
{
@@ -24810,13 +24810,13 @@
},
{
"id": "manual-update",
- "titleZh": "手动检查、下载并安装可用更新,然后按需重启",
- "titleEn": "Check for, download, and install an available update manually, then restart when needed",
+ "titleZh": "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装",
+ "titleEn": "Check and download updates in the background, then confirm installation and restart or install later from About",
"control": {
"kind": "open",
"reasonCode": "unstructuredInteraction",
- "reasonZh": "“手动检查、下载并安装可用更新,然后按需重启”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。",
- "reasonEn": "“Check for, download, and install an available update manually, then restart when needed” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user."
+ "reasonZh": "“手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装”需要下载后由用户确认安装与重启;Agent 会打开精确入口,并把后续交互保留在用户可见界面。",
+ "reasonEn": "“Check and download updates in the background, then confirm installation and restart or install later from About” requires user confirmation after downloading, before installation and restart; the Agent opens the exact entry and keeps the remaining interaction visible to the user."
}
},
{
@@ -25020,8 +25020,8 @@
"Completion, permission, and startup-tip notifications",
"启用或停用自动检查更新",
"Enable or disable automatic update checks",
- "手动检查、下载并安装可用更新,然后按需重启",
- "Check for, download, and install an available update manually, then restart when needed",
+ "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装",
+ "Check and download updates in the background, then confirm installation and restart or install later from About",
"控制 OpenBitFun 是否在登录系统后自动启动",
"Choose whether OpenBitFun launches automatically after system sign-in",
"控制 OpenBitFun 运行期间是否阻止电脑自动睡眠",
diff --git a/docs/interactive-capabilities/capabilities/setting.application.general.md b/docs/interactive-capabilities/capabilities/setting.application.general.md
index d53615f660..405590448f 100644
--- a/docs/interactive-capabilities/capabilities/setting.application.general.md
+++ b/docs/interactive-capabilities/capabilities/setting.application.general.md
@@ -19,8 +19,8 @@ Manage app-level preferences for startup, updates, close behavior, notifications
- **Agent 可直接控制 / Direct Agent control** · 启用或停用自动检查更新
- Enable or disable automatic update checks
-- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 手动检查、下载并安装可用更新,然后按需重启
- - Check for, download, and install an available update manually, then restart when needed
+- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装
+ - Check and download updates in the background, then confirm installation and restart or install later from About
- **Agent 可直接控制 / Direct Agent control** · 控制 OpenBitFun 是否在登录系统后自动启动
- Choose whether OpenBitFun launches automatically after system sign-in
- **Agent 可直接控制 / Direct Agent control** · 控制 OpenBitFun 运行期间是否阻止电脑自动睡眠
diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json
index 8e3dafa2f8..ff540e156f 100644
--- a/docs/interactive-capabilities/technical/product-control-open-audit.json
+++ b/docs/interactive-capabilities/technical/product-control-open-audit.json
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"generatedFrom": "src/shared/interactive-capabilities/catalog.json",
- "catalogDigest": "c21d28e130de07890a2ebd6793cf78905c7749f577d8b8e547d9fe12a6e26a95",
+ "catalogDigest": "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3",
"count": 211,
"reasonCounts": {
"externalAuth": 4,
@@ -2471,19 +2471,20 @@
{
"capabilityId": "setting.application.general",
"itemId": "manual-update",
- "titleZh": "手动检查、下载并安装可用更新,然后按需重启",
- "titleEn": "Check for, download, and install an available update manually, then restart when needed",
+ "titleZh": "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装",
+ "titleEn": "Check and download updates in the background, then confirm installation and restart or install later from About",
"reasonCode": "unstructuredInteraction",
- "reasonZh": "“手动检查、下载并安装可用更新,然后按需重启”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。",
- "reasonEn": "“Check for, download, and install an available update manually, then restart when needed” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.",
+ "reasonZh": "“手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装”需要下载后由用户确认安装与重启;Agent 会打开精确入口,并把后续交互保留在用户可见界面。",
+ "reasonEn": "“Check and download updates in the background, then confirm installation and restart or install later from About” requires user confirmation after downloading, before installation and restart; the Agent opens the exact entry and keeps the remaining interaction visible to the user.",
"presentationTarget": {
"kind": "settings",
"pageId": "application.general"
},
"evidence": [
"command:check_for_updates",
- "command:install_update",
- "command:restart_app",
+ "command:download_update",
+ "command:get_pending_update",
+ "command:install_pending_update",
"command:get_app_version"
]
},
diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json
index b87eb0ffd0..106e854522 100644
--- a/docs/interactive-capabilities/technical/tauri-command-map.json
+++ b/docs/interactive-capabilities/technical/tauri-command-map.json
@@ -1,13 +1,13 @@
{
"schemaVersion": 2,
"generatedFrom": "src/shared/interactive-capabilities/catalog.json",
- "catalogDigest": "c21d28e130de07890a2ebd6793cf78905c7749f577d8b8e547d9fe12a6e26a95",
- "commandCount": 662,
+ "catalogDigest": "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3",
+ "commandCount": 665,
"coverage": {
- "commandCount": 662,
- "documentedCommandCount": 631,
- "implementationCommandCount": 31,
- "implementationDigest": "859a3a3cb1e4e27ca38cd9003cef14e8758483ce85417a2c22c6ab92df9dd2c3"
+ "commandCount": 665,
+ "documentedCommandCount": 632,
+ "implementationCommandCount": 33,
+ "implementationDigest": "35539d9c1510287cb47f4a68fe35859b78f93bb06cd66b86e58d878a48d8c509"
},
"commands": [
{
@@ -2290,6 +2290,22 @@
"signature": "fn download_skill_market( _state: State<'_, AppState>, request: SkillMarketDownloadRequest, ) -> Result",
"remoteWorkspacePolicy": "LegacyUnaudited"
},
+ {
+ "id": "download_update",
+ "moduleId": "update",
+ "capabilityId": "setting.application.general",
+ "capabilityIds": [
+ "setting.application.general"
+ ],
+ "documentedItemIds": [
+ "setting.application.general:manual-update"
+ ],
+ "visibility": "documented",
+ "rustPath": "api::update_api::download_update",
+ "sourceFile": "src/apps/desktop/src/api/update_api.rs",
+ "signature": "fn download_update( app: AppHandle, request: PendingUpdateRequest, ) -> Result",
+ "remoteWorkspacePolicy": "WorkspaceAgnostic"
+ },
{
"id": "editor_ai_cancel",
"moduleId": "editor_ai",
@@ -3509,6 +3525,22 @@
"signature": "fn get_pending_announcements( state: State<'_, AppState>, ) -> Result, String>",
"remoteWorkspacePolicy": "WorkspaceAgnostic"
},
+ {
+ "id": "get_pending_update",
+ "moduleId": "update",
+ "capabilityId": "setting.application.general",
+ "capabilityIds": [
+ "setting.application.general"
+ ],
+ "documentedItemIds": [
+ "setting.application.general:manual-update"
+ ],
+ "visibility": "documented",
+ "rustPath": "api::update_api::get_pending_update",
+ "sourceFile": "src/apps/desktop/src/api/update_api.rs",
+ "signature": "fn get_pending_update( app: AppHandle, request: PendingUpdateRequest, ) -> Result
) : null}
- {updateStatus === 'installed' ? (
+ {updateStatus === 'ready' || updateStatus === 'installing' ? (
- {t('update.installedMessage')}
+ {t('update.readyVersion', { version: updateVersion ?? '' })}
-
) : null}
diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json
index 448472ae95..a635ba4c23 100644
--- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json
+++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json
@@ -4,7 +4,7 @@
"title": "OpenBitFun Playbook",
"origin": "https://playbook.openbitfun.com",
"source": "src/shared/interactive-capabilities/catalog.json",
- "digest": "c21d28e130de07890a2ebd6793cf78905c7749f577d8b8e547d9fe12a6e26a95",
+ "digest": "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3",
"ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54",
"searchAcceptance": [
{
@@ -24810,13 +24810,13 @@
},
{
"id": "manual-update",
- "titleZh": "手动检查、下载并安装可用更新,然后按需重启",
- "titleEn": "Check for, download, and install an available update manually, then restart when needed",
+ "titleZh": "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装",
+ "titleEn": "Check and download updates in the background, then confirm installation and restart or install later from About",
"control": {
"kind": "open",
"reasonCode": "unstructuredInteraction",
- "reasonZh": "“手动检查、下载并安装可用更新,然后按需重启”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。",
- "reasonEn": "“Check for, download, and install an available update manually, then restart when needed” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user."
+ "reasonZh": "“手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装”需要下载后由用户确认安装与重启;Agent 会打开精确入口,并把后续交互保留在用户可见界面。",
+ "reasonEn": "“Check and download updates in the background, then confirm installation and restart or install later from About” requires user confirmation after downloading, before installation and restart; the Agent opens the exact entry and keeps the remaining interaction visible to the user."
}
},
{
@@ -25020,8 +25020,8 @@
"Completion, permission, and startup-tip notifications",
"启用或停用自动检查更新",
"Enable or disable automatic update checks",
- "手动检查、下载并安装可用更新,然后按需重启",
- "Check for, download, and install an available update manually, then restart when needed",
+ "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装",
+ "Check and download updates in the background, then confirm installation and restart or install later from About",
"控制 OpenBitFun 是否在登录系统后自动启动",
"Choose whether OpenBitFun launches automatically after system sign-in",
"控制 OpenBitFun 运行期间是否阻止电脑自动睡眠",
diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts
index 438c985268..59a760dce6 100644
--- a/src/web-ui/src/infrastructure/api/generated/productControl.ts
+++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts
@@ -1,5 +1,5 @@
// Generated by scripts/generate-interactive-capabilities.mjs; do not edit.
-export const PRODUCT_CONTROL_GRAPH_DIGEST = "c21d28e130de07890a2ebd6793cf78905c7749f577d8b8e547d9fe12a6e26a95" as const;
+export const PRODUCT_CONTROL_GRAPH_DIGEST = "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3" as const;
export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics";
diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.test.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.test.ts
index 7c446db791..62e510a061 100644
--- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.test.ts
+++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.test.ts
@@ -73,6 +73,9 @@ describe('remote surface generated bindings', () => {
'dispatch_submit',
'mark_openbitfun_control_surface_ready',
'show_main_window',
+ 'download_update',
+ 'get_pending_update',
+ 'install_pending_update',
]) {
expect(PEER_CONTROLLER_LOCAL_COMMANDS.has(command), command).toBe(true);
}
diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts
index 8839abb09d..1b3b3553d9 100644
--- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts
+++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts
@@ -1,6 +1,6 @@
// Generated by scripts/generate-interactive-capabilities.mjs; do not edit.
// Source: openbitfun_product_domains::remote_surface (Product Operation Registry).
-export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:4428afcddfc4d58f" as const;
+export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:90117ec8e98d6d48" as const;
/**
* Registered Tauri commands the Peer Device controller keeps on the controller
@@ -76,6 +76,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([
"dispatch_submit",
"dispatch_sync_model_config",
"dispatch_sync_result",
+ "download_update",
"frontend_update_candidate_failed",
"frontend_update_candidate_ready",
"generate_insights",
@@ -83,6 +84,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([
"get_frontend_update_status",
"get_latest_insights",
"get_pending_announcements",
+ "get_pending_update",
"get_prevent_sleep_enabled",
"has_insights_data",
"hide_agent_companion_desktop_pet",
@@ -96,6 +98,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([
"i18n_set_language",
"import_agent_companion_pet_package",
"initialize_tray_after_startup",
+ "install_pending_update",
"install_update",
"list_agent_companion_pets",
"load_insights_report",
diff --git a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.test.ts
index d668e192fc..3b4e4aa513 100644
--- a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.test.ts
+++ b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.test.ts
@@ -67,6 +67,21 @@ describe('SystemAPI', () => {
});
});
+ it('allows a background download to outlive the default request timeout without replaying it', async () => {
+ invokeMock.mockResolvedValueOnce({ version: '2.0.0' });
+ await expect(systemAPI.downloadUpdate()).resolves.toEqual({ version: '2.0.0' });
+ expect(invokeMock).toHaveBeenCalledWith('download_update', { request: {} }, {
+ timeout: 3600000, retries: 0,
+ });
+ });
+
+ it('installs only the version the user confirmed and disables automatic mutation retries', async () => {
+ await systemAPI.installPendingUpdate('2.0.0');
+ expect(invokeMock).toHaveBeenCalledWith('install_pending_update', {
+ request: { version: '2.0.0' },
+ }, { timeout: 120000, retries: 0 });
+ });
+
it('sends the requested app-wide state', async () => {
invokeMock.mockResolvedValueOnce(undefined);
diff --git a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts
index 1b3f7bfc2f..62f68e3658 100644
--- a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts
+++ b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts
@@ -19,6 +19,10 @@ export interface CheckForUpdatesResponse {
releaseDate: string | null;
}
+export interface PendingUpdateResponse {
+ version: string;
+}
+
/** Matches `toggle_main_window_fullscreen` / desktop `ToggleMainWindowFullscreenResponse`. */
export interface ToggleMainWindowFullscreenResponse {
isFullscreen: boolean;
@@ -76,6 +80,37 @@ export class SystemAPI {
}
}
+ /** Download and verify without starting the installer. */
+ async downloadUpdate(): Promise {
+ try {
+ return await api.invoke('download_update', { request: {} }, {
+ timeout: 60 * 60 * 1000,
+ retries: 0,
+ });
+ } catch (error) {
+ throw createTauriCommandError('download_update', error);
+ }
+ }
+
+ async getPendingUpdate(): Promise {
+ try {
+ return await api.invoke('get_pending_update', { request: {} });
+ } catch (error) {
+ throw createTauriCommandError('get_pending_update', error);
+ }
+ }
+
+ async installPendingUpdate(version: string): Promise {
+ try {
+ await api.invoke('install_pending_update', { request: { version } }, {
+ timeout: 120000,
+ retries: 0,
+ });
+ } catch (error) {
+ throw createTauriCommandError('install_pending_update', error);
+ }
+ }
+
/** Desktop only: restart the app after an update has been installed. */
async restartApp(): Promise {
try {
diff --git a/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.tsx b/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.tsx
index 19c157d7db..4d095540f1 100644
--- a/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.tsx
+++ b/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.tsx
@@ -13,6 +13,8 @@ import {
import { UpdateAvailableDialog } from './UpdateAvailableDialog';
import { UpdateInstallProgressModal } from './UpdateInstallProgressModal';
import { useUpdateInstallStore } from './updateInstallStore';
+import { useI18n } from '@/infrastructure/i18n';
+import { notificationService } from '@/shared/notification-system';
const log = createLogger('DailyAppUpdate');
@@ -21,6 +23,7 @@ const log = createLogger('DailyAppUpdate');
* Renders update dialogs; mount once near the app root (e.g. inside AppLayout).
*/
export function DailyAppUpdateGate(): ReactElement | null {
+ const { t } = useI18n('common');
const [dailyOpen, setDailyOpen] = useState(false);
const [dailyData, setDailyData] = useState(null);
const dailyCheckTimerRef = useRef(null);
@@ -29,7 +32,10 @@ export function DailyAppUpdateGate(): ReactElement | null {
const updateError = useUpdateInstallStore(state => state.error);
const startUpdateInstall = useUpdateInstallStore(state => state.startInstall);
const clearUpdateError = useUpdateInstallStore(state => state.clearError);
- const clearUpdateInstalled = useUpdateInstallStore(state => state.clearInstalled);
+ const promptOpen = useUpdateInstallStore(state => state.promptOpen);
+ const updateVersion = useUpdateInstallStore(state => state.version);
+ const deferInstall = useUpdateInstallStore(state => state.deferInstall);
+ const confirmInstall = useUpdateInstallStore(state => state.confirmInstall);
useEffect(() => {
if (!canCheckForAppUpdates()) {
@@ -37,6 +43,8 @@ export function DailyAppUpdateGate(): ReactElement | null {
}
let cancelled = false;
const runDailyCheck = async () => {
+ await useUpdateInstallStore.getState().initialize();
+ if (cancelled || useUpdateInstallStore.getState().status !== 'idle') return;
let autoUpdate = true;
try {
const v = await configManager.getConfig('app.auto_update');
@@ -57,7 +65,7 @@ export function DailyAppUpdateGate(): ReactElement | null {
return;
}
const res = await systemAPI.checkForUpdates();
- if (cancelled) {
+ if (cancelled || useUpdateInstallStore.getState().status !== 'idle') {
return;
}
if (!res.updateAvailable || !res.latestVersion) {
@@ -130,17 +138,10 @@ export function DailyAppUpdateGate(): ReactElement | null {
}, [clearUpdateError]);
const onCloseInstalled = useCallback(() => {
- clearUpdateInstalled();
- }, [clearUpdateInstalled]);
-
- const onRestart = useCallback(async () => {
- try {
- await systemAPI.restartApp();
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- useUpdateInstallStore.setState({ status: 'error', error: msg });
- }
- }, []);
+ if (useUpdateInstallStore.getState().status !== 'ready') return;
+ deferInstall();
+ notificationService.info(t('update.deferredMessage'));
+ }, [deferInstall, t]);
if (!isTauriRuntime()) {
return null;
@@ -157,13 +158,16 @@ export function DailyAppUpdateGate(): ReactElement | null {
onInstall={onInstall}
/>
void confirmInstall()}
+ onDownloadAgain={() => void startUpdateInstall(true)}
/>
>
);
diff --git a/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.test.tsx b/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.test.tsx
new file mode 100644
index 0000000000..e4b80c33b5
--- /dev/null
+++ b/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.test.tsx
@@ -0,0 +1,82 @@
+// @vitest-environment jsdom
+
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { UpdateInstallProgressModal } from './UpdateInstallProgressModal';
+import common from '@/locales/en-US/common.json';
+
+vi.mock('@/infrastructure/i18n', () => ({
+ useI18n: () => ({ t: (key: string, args?: Record) => {
+ const value = key.split('.').reduce((value, part) => (value as Record)?.[part], common);
+ return String(value ?? key).replace(/\{\{(\w+)\}\}/g, (_, name) => args?.[name] ?? '');
+ } }),
+}));
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+let root: Root;
+let container: HTMLDivElement;
+beforeEach(() => {
+ vi.stubGlobal('ResizeObserver', class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ });
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+});
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+const buttons = () => Array.from(document.querySelectorAll('button'));
+const button = (text: string) => buttons().find(button => button.textContent?.includes(text))!;
+
+it('shows the downloaded version and interruption notice before allowing installation', async () => {
+ const install = vi.fn();
+ const defer = vi.fn();
+ await act(async () => root.render());
+ expect(document.body.textContent).toContain('Version 2.0.0 is downloaded');
+ expect(document.body.textContent).toContain('interrupt its active sessions');
+ expect(install).not.toHaveBeenCalled();
+ await act(async () => button(common.update.restartLater).click());
+ expect(defer).toHaveBeenCalledOnce();
+ expect(install).not.toHaveBeenCalled();
+ await act(async () => button(common.update.installAndRestart).click());
+ expect(install).toHaveBeenCalledOnce();
+});
+
+it('keeps retry and defer actions available after an install error', async () => {
+ const download = vi.fn();
+ await act(async () => root.render());
+ expect(button(common.update.installAndRestart).disabled).toBe(false);
+ expect(button(common.update.restartLater).disabled).toBe(false);
+ await act(async () => button(common.update.downloadAgain).click());
+ expect(download).toHaveBeenCalledOnce();
+});
+
+it('prevents dismissing or submitting the dialog again while installation is in progress', async () => {
+ const defer = vi.fn();
+ const install = vi.fn();
+ await act(async () => root.render());
+ expect(button(common.update.restartLater).disabled).toBe(true);
+ expect(button(common.update.installing).disabled).toBe(true);
+ await act(async () => {
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ button(common.update.installing).click();
+ });
+ expect(defer).not.toHaveBeenCalled();
+ expect(install).not.toHaveBeenCalled();
+});
diff --git a/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.tsx b/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.tsx
index a9236fdc38..0dd9b50a99 100644
--- a/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.tsx
+++ b/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.tsx
@@ -22,20 +22,26 @@ export interface UpdateInstallProgressModalProps {
isOpen: boolean;
error: string | null;
installed?: boolean;
+ installing?: boolean;
+ version?: string | null;
progress: UpdateDownloadProgressPayload;
onCloseError?: () => void;
onCloseInstalled?: () => void;
onRestart?: () => void;
+ onDownloadAgain?: () => void;
}
export const UpdateInstallProgressModal: React.FC = ({
isOpen,
error,
installed,
+ installing,
+ version,
progress,
onCloseError,
onCloseInstalled,
- onRestart
+ onRestart,
+ onDownloadAgain
}) => {
const { t } = useI18n('common');
const { downloaded, total } = progress;
@@ -47,21 +53,23 @@ export const UpdateInstallProgressModal: React.FC {};
- if (error) {
- onClose = onCloseError ?? (() => {});
+ if (installing) {
+ onClose = () => {};
} else if (installed) {
onClose = onCloseInstalled ?? (() => {});
+ } else if (error) {
+ onClose = onCloseError ?? (() => {});
}
let body: React.ReactNode = null;
- if (errorMessage) {
+ if (errorMessage && !installed) {
body = (
+ {t('update.installWarning')}
+ {errorMessage ? : null}
-
+ {errorMessage && onDownloadAgain ? (
+
+ {t('update.downloadAgain')}
+
+ ) : null}
+
{t('update.restartLater')}
-
- {t('update.restartNow')}
+
+ {t(installing ? 'update.installing' : 'update.installAndRestart')}
>
@@ -138,7 +153,7 @@ export const UpdateInstallProgressModal: React.FC
{title}
- {!!error || !!installed && }
+ {!installing && (!!error || !!installed) && }
({ download: vi.fn(), install: vi.fn(), listen: vi.fn(), unlisten: vi.fn() }));
+vi.mock('@/infrastructure/api', () => ({ systemAPI: { downloadUpdate: mocks.download, installUpdate: mocks.install } }));
+vi.mock('@tauri-apps/api/event', () => ({ listen: mocks.listen }));
+vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn() }) }));
+
+it('subscribes before downloading, returns the prepared version and never installs', async () => {
+ mocks.listen.mockImplementation(async (_name, callback) => {
+ callback({ payload: { downloaded: 12, total: null } });
+ return mocks.unlisten;
+ });
+ mocks.download.mockResolvedValue({ version: '2.0.0' });
+ const progress = vi.fn();
+ await expect(installUpdateWithProgress(progress)).resolves.toEqual({ version: '2.0.0' });
+ expect(progress).toHaveBeenCalledWith({ downloaded: 12, total: null });
+ expect(mocks.unlisten).toHaveBeenCalledOnce();
+ expect(mocks.install).not.toHaveBeenCalled();
+});
+
+it('cleans up its progress listener when signature verification fails', async () => {
+ mocks.unlisten.mockClear();
+ mocks.listen.mockResolvedValue(mocks.unlisten);
+ mocks.download.mockRejectedValue(new Error('signature invalid'));
+ await expect(installUpdateWithProgress(vi.fn())).rejects.toThrow('signature invalid');
+ expect(mocks.unlisten).toHaveBeenCalledOnce();
+});
diff --git a/src/web-ui/src/infrastructure/update/installUpdateWithProgress.ts b/src/web-ui/src/infrastructure/update/installUpdateWithProgress.ts
index d1e7728c2a..c2a13a4b75 100644
--- a/src/web-ui/src/infrastructure/update/installUpdateWithProgress.ts
+++ b/src/web-ui/src/infrastructure/update/installUpdateWithProgress.ts
@@ -11,12 +11,11 @@ export interface UpdateDownloadProgressPayload {
}
/**
- * Subscribes to Rust-emitted download progress, then runs `install_update`.
- * Unsubscribes when the install promise settles.
+ * Downloads and verifies only. Installation requires a separate confirmation.
*/
export async function installUpdateWithProgress(
onProgress: (p: UpdateDownloadProgressPayload) => void
-): Promise {
+): Promise {
const { listen } = await import('@tauri-apps/api/event');
const unlisten = await listen(
UPDATE_PROGRESS_EVENT,
@@ -29,9 +28,9 @@ export async function installUpdateWithProgress(
}
);
try {
- await systemAPI.installUpdate();
+ return await systemAPI.downloadUpdate();
} catch (error) {
- log.error('install_update failed', error);
+ log.error('Update download failed', error);
throw error;
} finally {
unlisten();
diff --git a/src/web-ui/src/infrastructure/update/updateInstallStore.test.ts b/src/web-ui/src/infrastructure/update/updateInstallStore.test.ts
new file mode 100644
index 0000000000..9705ac75b0
--- /dev/null
+++ b/src/web-ui/src/infrastructure/update/updateInstallStore.test.ts
@@ -0,0 +1,102 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useUpdateInstallStore } from './updateInstallStore';
+
+const mocks = vi.hoisted(() => ({ pending: vi.fn(), download: vi.fn(), install: vi.fn() }));
+vi.mock('@/infrastructure/api', () => ({ systemAPI: {
+ getPendingUpdate: mocks.pending,
+ installPendingUpdate: mocks.install,
+} }));
+vi.mock('./installUpdateWithProgress', () => ({ installUpdateWithProgress: mocks.download }));
+vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn() }) }));
+
+const state = () => useUpdateInstallStore.getState();
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ mocks.pending.mockResolvedValue(null);
+ mocks.download.mockResolvedValue({ version: '2.0.0' });
+ mocks.install.mockResolvedValue(undefined);
+ useUpdateInstallStore.setState({
+ status: 'idle', progress: { downloaded: 0, total: null }, error: null,
+ startedAt: null, version: null, promptOpen: false, initialized: false,
+ });
+});
+
+describe('staged app update', () => {
+ it('downloads in the background, then requests confirmation without installing', async () => {
+ mocks.download.mockImplementation(async (progress) => {
+ expect(state().status).toBe('downloading');
+ expect(state().promptOpen).toBe(false);
+ progress({ downloaded: 100, total: 100 });
+ return { version: '2.0.0' };
+ });
+ await state().startInstall();
+ expect(state()).toMatchObject({ status: 'ready', version: '2.0.0', promptOpen: true });
+ expect(mocks.install).not.toHaveBeenCalled();
+ });
+
+ it('defers without discarding the package and installs from About without downloading again', async () => {
+ await state().startInstall();
+ state().deferInstall();
+ expect(state()).toMatchObject({ status: 'ready', version: '2.0.0', promptOpen: false });
+ await state().confirmInstall();
+ expect(mocks.install).not.toHaveBeenCalled();
+ state().requestInstall();
+ await state().confirmInstall();
+ expect(mocks.install).toHaveBeenCalledWith('2.0.0');
+ expect(mocks.download).toHaveBeenCalledTimes(1);
+ expect(state().status).toBe('installing');
+ });
+
+ it('restores a downloaded update after restart without forcing a new prompt', async () => {
+ mocks.pending.mockResolvedValue({ version: '2.0.0' });
+ await state().initialize();
+ await state().startInstall();
+ expect(state()).toMatchObject({ status: 'ready', version: '2.0.0', promptOpen: false });
+ expect(mocks.download).not.toHaveBeenCalled();
+ expect(mocks.install).not.toHaveBeenCalled();
+ });
+
+ it('does not mark a failed download or signature check ready', async () => {
+ mocks.download.mockRejectedValue(new Error('signature invalid'));
+ await state().startInstall();
+ expect(state()).toMatchObject({ status: 'error', version: null, promptOpen: false });
+ await state().confirmInstall();
+ expect(mocks.install).not.toHaveBeenCalled();
+ });
+
+ it('retains the pending version after an install failure and permits retry', async () => {
+ await state().startInstall();
+ mocks.install.mockRejectedValueOnce(new Error('installer unavailable'));
+ await state().confirmInstall();
+ expect(state()).toMatchObject({ status: 'ready', version: '2.0.0', promptOpen: true, error: 'installer unavailable' });
+ await state().confirmInstall();
+ expect(mocks.install).toHaveBeenCalledTimes(2);
+ expect(mocks.download).toHaveBeenCalledTimes(1);
+ });
+
+ it('coalesces startup reads and prevents duplicate downloads and installs', async () => {
+ let finish!: (value: { version: string }) => void;
+ mocks.download.mockImplementation(() => new Promise(resolve => { finish = resolve; }));
+ const first = state().startInstall();
+ const second = state().startInstall();
+ await vi.waitFor(() => expect(mocks.download).toHaveBeenCalledTimes(1));
+ finish({ version: '2.0.0' });
+ await Promise.all([first, second]);
+ expect(mocks.pending).toHaveBeenCalledTimes(1);
+ await Promise.all([state().confirmInstall(), state().confirmInstall()]);
+ expect(mocks.install).toHaveBeenCalledTimes(1);
+ state().deferInstall();
+ expect(state().status).toBe('installing');
+ });
+
+ it('allows an explicit replacement download after a cached package fails installation', async () => {
+ await state().startInstall();
+ mocks.install.mockRejectedValueOnce(new Error('package corrupt'));
+ await state().confirmInstall();
+ mocks.download.mockResolvedValueOnce({ version: '2.1.0' });
+ await state().startInstall(true);
+ expect(state()).toMatchObject({ status: 'ready', version: '2.1.0', error: null, promptOpen: true });
+ expect(mocks.download).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/web-ui/src/infrastructure/update/updateInstallStore.ts b/src/web-ui/src/infrastructure/update/updateInstallStore.ts
index 207e1c1ed5..1012b8bbfc 100644
--- a/src/web-ui/src/infrastructure/update/updateInstallStore.ts
+++ b/src/web-ui/src/infrastructure/update/updateInstallStore.ts
@@ -1,75 +1,89 @@
import { create } from 'zustand';
import { createLogger } from '@/shared/utils/logger';
-import {
- installUpdateWithProgress,
- type UpdateDownloadProgressPayload
-} from './installUpdateWithProgress';
+import { systemAPI } from '@/infrastructure/api';
+import { installUpdateWithProgress, type UpdateDownloadProgressPayload } from './installUpdateWithProgress';
const log = createLogger('UpdateInstallStore');
-export type UpdateInstallStatus = 'idle' | 'downloading' | 'installed' | 'error';
+export type UpdateInstallStatus = 'idle' | 'downloading' | 'ready' | 'installing' | 'error';
interface UpdateInstallState {
status: UpdateInstallStatus;
progress: UpdateDownloadProgressPayload;
error: string | null;
startedAt: number | null;
- startInstall: () => Promise;
+ version: string | null;
+ promptOpen: boolean;
+ initialized: boolean;
+ initialize: () => Promise;
+ startInstall: (replacePending?: boolean) => Promise;
+ requestInstall: () => void;
+ confirmInstall: () => Promise;
+ deferInstall: () => void;
clearError: () => void;
- clearInstalled: () => void;
}
-const initialProgress: UpdateDownloadProgressPayload = {
- downloaded: 0,
- total: null
-};
+const initialProgress: UpdateDownloadProgressPayload = { downloaded: 0, total: null };
+let initialization: Promise | null = null;
export const useUpdateInstallStore = create((set, get) => ({
- status: 'idle',
- progress: initialProgress,
- error: null,
- startedAt: null,
+ status: 'idle', progress: initialProgress, error: null, startedAt: null,
+ version: null, promptOpen: false, initialized: false,
- startInstall: async () => {
- const status = get().status;
- if (status === 'downloading' || status === 'installed') {
- return;
+ initialize: async () => {
+ if (get().initialized) return;
+ if (initialization) return initialization;
+ initialization = (async () => {
+ try {
+ const pending = await systemAPI.getPendingUpdate();
+ if (pending) set({ status: 'ready', version: pending.version });
+ } catch (error) {
+ log.error('Failed to restore pending update', error);
+ set({ status: 'error', error: String(error) });
+ } finally {
+ set({ initialized: true });
+ }
+ })();
+ try { await initialization; } finally { initialization = null; }
+ },
+
+ // Both daily and manual prompts use this download-only preparation step.
+ startInstall: async (replacePending = false) => {
+ await get().initialize();
+ if (['downloading', 'installing'].includes(get().status)) return;
+ if (get().status === 'ready' && !replacePending) return;
+ set({ status: 'downloading', progress: initialProgress, error: null, startedAt: Date.now(), promptOpen: false });
+ try {
+ const pending = await installUpdateWithProgress(progress => set({ progress }));
+ set({ status: 'ready', version: pending.version, promptOpen: true });
+ } catch (error) {
+ log.error('Update download failed', error);
+ set({ status: 'error', error: error instanceof Error ? error.message : String(error) });
}
+ },
- set({
- status: 'downloading',
- progress: initialProgress,
- error: null,
- startedAt: Date.now()
- });
+ requestInstall: () => {
+ if (get().status === 'ready') set({ promptOpen: true, error: null });
+ },
+ confirmInstall: async () => {
+ const { status, version, promptOpen } = get();
+ if (status !== 'ready' || !version || !promptOpen) return;
+ set({ status: 'installing', error: null });
try {
- await installUpdateWithProgress(progress => {
- set({ progress });
- });
- set({ status: 'installed', error: null });
+ await systemAPI.installPendingUpdate(version);
+ // Successful installation restarts the host. Keep the action locked until exit.
} catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- log.error('Background update install failed', error);
- set({ status: 'error', error: message });
+ log.error('Update installation failed', error);
+ set({ status: 'ready', error: error instanceof Error ? error.message : String(error) });
}
},
- clearError: () => {
- set({
- status: 'idle',
- error: null,
- progress: initialProgress,
- startedAt: null
- });
+ deferInstall: () => {
+ if (get().status === 'ready') set({ promptOpen: false, error: null });
},
- clearInstalled: () => {
- set({
- status: 'idle',
- error: null,
- progress: initialProgress,
- startedAt: null
- });
- }
+ clearError: () => {
+ set({ status: get().version ? 'ready' : 'idle', error: null, promptOpen: false });
+ },
}));
diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json
index cf518b075c..b159cf26fa 100644
--- a/src/web-ui/src/locales/en-US/common.json
+++ b/src/web-ui/src/locales/en-US/common.json
@@ -1087,12 +1087,18 @@
"backgroundInstall": "Download in background",
"downloadingTitle": "Downloading update",
"downloadFailedTitle": "Update could not be installed",
- "installedTitle": "Update installed",
+ "installedTitle": "Update ready",
+ "readyVersion": "Version {{version}} is downloaded and ready to install.",
+ "installWarning": "Installing will restart OpenBitFun on this device and interrupt its active sessions.",
+ "installAndRestart": "Install and restart",
+ "installing": "Installing…",
+ "deferredMessage": "The update is saved. Open About and select Install and restart whenever you are ready.",
+ "downloadAgain": "Download again",
"installedMessage": "The update has been installed. Restart OpenBitFun to start using the new version.",
"progressPercent": "{{percent}}%",
"progressUnknown": "Downloading…",
"backgroundDownloading": "Downloading in background",
- "backgroundDownloadHint": "You can keep using OpenBitFun. Restart will be offered after installation finishes.",
+ "backgroundDownloadHint": "You can keep using OpenBitFun. When the download finishes, you can choose whether to install and restart.",
"restartHint": "The app will need to restart after installation completes.",
"restartNow": "Restart now",
"restartLater": "Later",
diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json
index c2ad3aafdd..e11922babe 100644
--- a/src/web-ui/src/locales/zh-CN/common.json
+++ b/src/web-ui/src/locales/zh-CN/common.json
@@ -1087,12 +1087,18 @@
"backgroundInstall": "后台下载",
"downloadingTitle": "正在下载更新",
"downloadFailedTitle": "无法完成更新",
- "installedTitle": "更新已安装",
+ "installedTitle": "更新已准备就绪",
+ "readyVersion": "版本 {{version}} 已下载,可以安装并重启。",
+ "installWarning": "安装将重启本设备上的 OpenBitFun,并中断此设备上的活动会话。",
+ "installAndRestart": "安装并重启",
+ "installing": "正在安装…",
+ "deferredMessage": "更新已保留,可随时前往「关于」点击「安装并重启」。",
+ "downloadAgain": "重新下载",
"installedMessage": "更新已安装。重启 OpenBitFun 后即可使用新版本。",
"progressPercent": "{{percent}}%",
"progressUnknown": "正在下载…",
"backgroundDownloading": "正在后台下载",
- "backgroundDownloadHint": "下载完成后会提示重启,期间可以继续使用 OpenBitFun。",
+ "backgroundDownloadHint": "下载完成后会询问是否安装并重启,期间可以继续使用 OpenBitFun。",
"restartHint": "安装完成后需要重启应用。",
"restartNow": "立即重启",
"restartLater": "稍后",
diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json
index f2e07b2b56..4355b7c96c 100644
--- a/src/web-ui/src/locales/zh-TW/common.json
+++ b/src/web-ui/src/locales/zh-TW/common.json
@@ -1087,12 +1087,18 @@
"backgroundInstall": "背景下載",
"downloadingTitle": "正在下載更新",
"downloadFailedTitle": "無法完成更新",
- "installedTitle": "更新已安裝",
+ "installedTitle": "更新已準備就緒",
+ "readyVersion": "版本 {{version}} 已下載,可以安裝並重啟。",
+ "installWarning": "安裝將重啟本裝置上的 OpenBitFun,並中斷此裝置上的活動會話。",
+ "installAndRestart": "安裝並重啟",
+ "installing": "正在安裝…",
+ "deferredMessage": "更新已保留,可隨時前往「關於」點擊「安裝並重啟」。",
+ "downloadAgain": "重新下載",
"installedMessage": "更新已安裝。重啟 OpenBitFun 後即可使用新版本。",
"progressPercent": "{{percent}}%",
"progressUnknown": "正在下載…",
"backgroundDownloading": "正在背景下載",
- "backgroundDownloadHint": "下載完成後會提示重啟,期間可以繼續使用 OpenBitFun。",
+ "backgroundDownloadHint": "下載完成後會詢問是否安裝並重啟,期間可以繼續使用 OpenBitFun。",
"restartHint": "安裝完成後需要重啟應用。",
"restartNow": "立即重啟",
"restartLater": "稍後",