From 00d70d2934fe2707ddf2ec9400e04c59f650231d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 5 Jul 2026 20:06:51 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(update):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Windows=20=E6=9B=B4=E6=96=B0=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E5=8D=A1=E4=BD=8F=E5=B9=B6=E5=B0=86=E5=AE=89=E8=A3=85=E5=8C=85?= =?UTF-8?q?=E8=90=BD=E5=88=B0=E5=BA=94=E7=94=A8=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Windows 安装前预检当前安装目录写权限,避免脚本启动后主进程先退出 - 已下载更新补充打开安装目录入口,便于手动执行安装包 - 更新工作区优先改为当前应用运行目录,并补充回归测试与多语言文案 --- frontend/src/App.tsx | 11 ++- .../src/hooks/useAppUpdateManager.test.tsx | 30 +++++++ frontend/src/hooks/useAppUpdateManager.ts | 15 ++++ internal/app/methods_update.go | 63 +++++++++++++-- internal/app/methods_update_test.go | 81 +++++++++++++++++++ shared/i18n/de-DE.json | 2 + shared/i18n/en-US.json | 2 + shared/i18n/ja-JP.json | 2 + shared/i18n/ru-RU.json | 2 + shared/i18n/zh-CN.json | 2 + shared/i18n/zh-TW.json | 2 + 11 files changed, 204 insertions(+), 8 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18b0b628..d31c3d50 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1709,6 +1709,7 @@ function App() { lastUpdateInfo, markUpdateProgressDismissed, muteLatestUpdate, + openDownloadedUpdateDirectory, setIsAboutOpen, showUpdateDownloadProgress, updateChannel, @@ -4972,7 +4973,12 @@ function App() { , , lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded && !isBackgroundProgressForLatestUpdate ? ( - + + ) : null, + isLatestUpdateDownloaded ? ( + ) : null, isLatestUpdateDownloaded ? ( ] : (updateDownloadProgress.status === 'done' ? [ , + , diff --git a/frontend/src/hooks/useAppUpdateManager.test.tsx b/frontend/src/hooks/useAppUpdateManager.test.tsx index 1e17185c..6146a5da 100644 --- a/frontend/src/hooks/useAppUpdateManager.test.tsx +++ b/frontend/src/hooks/useAppUpdateManager.test.tsx @@ -183,4 +183,34 @@ describe('useAppUpdateManager', () => { expect(hook?.updateChannel).toBe('dev'); expect(hook?.lastUpdateInfo?.channel).toBe('dev'); }); + + it('opens the downloaded update directory when a package is already downloaded', async () => { + backendApp.CheckForUpdates.mockResolvedValue({ + success: true, + data: { + hasUpdate: true, + currentVersion: '0.8.1', + latestVersion: '0.8.2', + downloaded: true, + assetSize: 1024, + }, + }); + backendApp.OpenDownloadedUpdateDirectory.mockResolvedValue({ + success: true, + message: 'opened-install-directory', + }); + + renderHook(); + + await act(async () => { + await hook?.checkForUpdates(false); + }); + + await act(async () => { + await hook?.openDownloadedUpdateDirectory(); + }); + + expect(backendApp.OpenDownloadedUpdateDirectory).toHaveBeenCalledTimes(1); + expect(messageApi.success).toHaveBeenCalledWith('opened-install-directory'); + }); }); diff --git a/frontend/src/hooks/useAppUpdateManager.ts b/frontend/src/hooks/useAppUpdateManager.ts index c09d4bbd..6880d498 100644 --- a/frontend/src/hooks/useAppUpdateManager.ts +++ b/frontend/src/hooks/useAppUpdateManager.ts @@ -242,6 +242,20 @@ export const useAppUpdateManager = ({ hideUpdateDownloadProgress(); }, [hideUpdateDownloadProgress, lastUpdateInfo, lastUpdateKey, t, updateDownloadProgress.status]); + const openDownloadedUpdateDirectory = useCallback(async () => { + const backendApp = (window as any).go?.app?.App; + if (typeof backendApp?.OpenDownloadedUpdateDirectory !== 'function') { + void message.error(t('app.about.message.open_install_directory_failed_with_error', { error: t('common.unknown') })); + return; + } + const res = await backendApp.OpenDownloadedUpdateDirectory(); + if (!res?.success) { + void message.error(t('app.about.message.open_install_directory_failed_with_error', { error: res?.message || t('common.unknown') })); + return; + } + void message.success(res?.message || t('app.about.message.install_directory_opened_manual_replace')); + }, [t]); + const checkForUpdates = useCallback(async (silent: boolean) => { if (updateCheckInFlightRef.current) return; updateCheckInFlightRef.current = true; @@ -523,6 +537,7 @@ export const useAppUpdateManager = ({ lastUpdateInfo, markUpdateProgressDismissed, muteLatestUpdate, + openDownloadedUpdateDirectory, setIsAboutOpen, showUpdateDownloadProgress, updateChannel, diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index 4dbe8524..0926f629 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -34,10 +34,12 @@ const ( ) var ( - updateFetchLatestRelease = fetchLatestRelease - updateFetchDevRelease = fetchDevRelease - updateFetchReleaseSHA256 = fetchReleaseSHA256 - updateLogCheckError = func(err error) { logger.Error(err, "检查更新失败") } + updateFetchLatestRelease = fetchLatestRelease + updateFetchDevRelease = fetchDevRelease + updateFetchReleaseSHA256 = fetchReleaseSHA256 + updateLogCheckError = func(err error) { logger.Error(err, "检查更新失败") } + updateResolveInstallTarget = resolveUpdateInstallTarget + updateLaunchInstallScript = launchUpdateScript ) type updateState struct { @@ -244,7 +246,18 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult { return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.no_downloaded_package", nil)} } - if err := launchUpdateScript(staged); err != nil { + if stdRuntime.GOOS == "windows" { + if err := ensureWindowsUpdateTargetWritable(updateResolveInstallTarget()); err != nil { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.update.backend.message.install_launch_failed", map[string]any{ + "detail": a.localizedUpdateError(err), + }), + } + } + } + + if err := updateLaunchInstallScript(staged); err != nil { logger.Error(err, "启动更新脚本失败") detail := a.localizedUpdateError(err) msg := a.appText("app.update.backend.message.install_launch_failed", map[string]any{"detail": detail}) @@ -956,8 +969,7 @@ func resolveLegacyUpdateWorkspaceDir() string { } func resolveUpdateWorkspaceDir(version string) string { - // 默认使用系统临时目录作为更新工作区,避免目录权限与锁冲突。 - // macOS 用户要求更新包默认保存在桌面:Desktop/GoNavi-/。 + // macOS 更新包继续保存在桌面版本目录根级,方便用户直接处理 DMG。 if stdRuntime.GOOS == "darwin" { homeDir, err := os.UserHomeDir() if err == nil && strings.TrimSpace(homeDir) != "" { @@ -967,6 +979,16 @@ func resolveUpdateWorkspaceDir(version string) string { } } } + + // Windows / Linux 更新包优先落到当前应用运行目录,方便用户直接找到下载产物。 + targetPath := strings.TrimSpace(updateResolveInstallTarget()) + if targetPath != "" { + targetDir := strings.TrimSpace(filepath.Dir(targetPath)) + if targetDir != "" && targetDir != "." { + return targetDir + } + } + return resolveLegacyUpdateWorkspaceDir() } @@ -1094,6 +1116,33 @@ func resolveUpdateInstallTarget() string { return exePath } +func ensureWindowsUpdateTargetWritable(targetExe string) error { + targetExe = strings.TrimSpace(targetExe) + targetDir := strings.TrimSpace(filepath.Dir(targetExe)) + if targetExe == "" || targetDir == "" || targetDir == "." { + return localizedUpdateError{key: "app.update.backend.error.install_target_unresolved"} + } + + probePath := filepath.Join(targetDir, fmt.Sprintf(".gonavi-update-write-probe-%d.tmp", time.Now().UnixNano())) + file, err := os.OpenFile(probePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return localizedUpdateError{ + key: "app.update.backend.error.install_target_not_writable", + params: map[string]any{ + "path": targetDir, + "detail": err.Error(), + }, + } + } + if closeErr := file.Close(); closeErr != nil { + logger.Warnf("关闭 Windows 更新写入探针失败:%v", closeErr) + } + if removeErr := os.Remove(probePath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + logger.Warnf("清理 Windows 更新写入探针失败:%v", removeErr) + } + return nil +} + func (a *App) emitUpdateDownloadProgress(status string, downloaded, total int64, message string) { if a.ctx == nil { return diff --git a/internal/app/methods_update_test.go b/internal/app/methods_update_test.go index 7f197f05..4e614c35 100644 --- a/internal/app/methods_update_test.go +++ b/internal/app/methods_update_test.go @@ -340,6 +340,87 @@ func TestDownloadUpdateUsesCurrentLanguageForBackendMessage(t *testing.T) { } } +func TestEnsureWindowsUpdateTargetWritableAcceptsWritableDirectory(t *testing.T) { + if stdRuntime.GOOS != "windows" { + t.Skip("windows-only update target validation") + } + + target := filepath.Join(t.TempDir(), "GoNavi.exe") + if err := ensureWindowsUpdateTargetWritable(target); err != nil { + t.Fatalf("ensureWindowsUpdateTargetWritable returned error: %v", err) + } +} + +func TestInstallUpdateAndRestartFailsBeforeLaunchWhenWindowsTargetDirIsNotWritable(t *testing.T) { + if stdRuntime.GOOS != "windows" { + t.Skip("windows-only update target validation") + } + + stagedDir := t.TempDir() + assetPath := filepath.Join(stagedDir, "GoNavi-0.8.2-Windows-Amd64.exe") + if err := os.WriteFile(assetPath, []byte("12345678"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + app := NewApp() + app.updateState.staged = &stagedUpdate{ + Channel: updateChannelLatest, + Version: "0.8.2", + AssetName: filepath.Base(assetPath), + FilePath: assetPath, + StagedDir: stagedDir, + } + + originalResolveInstallTarget := updateResolveInstallTarget + originalLaunchInstallScript := updateLaunchInstallScript + t.Cleanup(func() { + updateResolveInstallTarget = originalResolveInstallTarget + updateLaunchInstallScript = originalLaunchInstallScript + }) + + updateResolveInstallTarget = func() string { + return filepath.Join(stagedDir, "missing", "GoNavi.exe") + } + + launched := false + updateLaunchInstallScript = func(*stagedUpdate) error { + launched = true + return nil + } + + result := app.InstallUpdateAndRestart() + if result.Success { + t.Fatalf("expected InstallUpdateAndRestart to fail, got %#v", result) + } + if launched { + t.Fatal("expected launch script to be skipped when install target is not writable") + } + if !strings.Contains(result.Message, "not writable") { + t.Fatalf("expected install target write failure in message, got %q", result.Message) + } +} + +func TestResolveUpdateWorkspaceDirPrefersCurrentInstallDirectory(t *testing.T) { + if stdRuntime.GOOS == "darwin" { + t.Skip("macOS keeps update downloads on Desktop") + } + + targetDir := t.TempDir() + originalResolveInstallTarget := updateResolveInstallTarget + t.Cleanup(func() { + updateResolveInstallTarget = originalResolveInstallTarget + }) + + updateResolveInstallTarget = func() string { + return filepath.Join(targetDir, "GoNavi.exe") + } + + got := resolveUpdateWorkspaceDir("0.8.2") + if got != targetDir { + t.Fatalf("expected workspace dir %q, got %q", targetDir, got) + } +} + func TestExpectedAssetNameForExecutableUsesLinuxWebKit41Suffix(t *testing.T) { assetName, err := expectedAssetNameForExecutable( "linux", diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 3a695270..09532d0b 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -2877,6 +2877,8 @@ "app.update.backend.error.sha256_missing_current_package": "SHA256SUMS enthält kein Updatepaket für die aktuelle Plattform", "app.update.backend.error.sha256sums_download_failed": "SHA256SUMS konnte nicht heruntergeladen werden: HTTP {{status}}", "app.update.backend.error.sha256sums_missing": "Release stellt keine SHA256SUMS bereit", + "app.update.backend.error.install_target_not_writable": "Das aktuelle Installationsverzeichnis ist nicht beschreibbar: {{path}} ({{detail}})", + "app.update.backend.error.install_target_unresolved": "Das aktuelle Installationsverzeichnis konnte nicht ermittelt werden", "app.update.backend.error.update_package_not_found": "Updatepaket nicht gefunden: {{name}}", "app.update.backend.message.app_directory_unavailable": "Auf das App-Verzeichnis kann nicht zugegriffen werden: {{path}}", "app.update.backend.message.app_directory_unresolved_download": "Das aktuelle App-Verzeichnis kann nicht bestimmt werden, daher kann das Update nicht heruntergeladen werden", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index ce4b74e1..266c3df7 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -2877,6 +2877,8 @@ "app.update.backend.error.sha256_missing_current_package": "SHA256SUMS does not include the current platform update package", "app.update.backend.error.sha256sums_download_failed": "Failed to download SHA256SUMS: HTTP {{status}}", "app.update.backend.error.sha256sums_missing": "Release did not provide SHA256SUMS", + "app.update.backend.error.install_target_not_writable": "The current install directory is not writable: {{path}} ({{detail}})", + "app.update.backend.error.install_target_unresolved": "Unable to determine the current install directory", "app.update.backend.error.update_package_not_found": "Update package not found: {{name}}", "app.update.backend.message.app_directory_unavailable": "Cannot access app directory: {{path}}", "app.update.backend.message.app_directory_unresolved_download": "Cannot determine the current app directory, so the update cannot be downloaded", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index cade019b..ddbbdbb4 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -2877,6 +2877,8 @@ "app.update.backend.error.sha256_missing_current_package": "SHA256SUMS に現在のプラットフォーム用更新パッケージが含まれていません", "app.update.backend.error.sha256sums_download_failed": "SHA256SUMS のダウンロードに失敗しました: HTTP {{status}}", "app.update.backend.error.sha256sums_missing": "Release に SHA256SUMS が提供されていません", + "app.update.backend.error.install_target_not_writable": "現在のインストール先ディレクトリに書き込めません: {{path}} ({{detail}})", + "app.update.backend.error.install_target_unresolved": "現在のインストール先ディレクトリを特定できません", "app.update.backend.error.update_package_not_found": "更新パッケージが見つかりません: {{name}}", "app.update.backend.message.app_directory_unavailable": "アプリディレクトリにアクセスできません: {{path}}", "app.update.backend.message.app_directory_unresolved_download": "現在のアプリディレクトリを特定できないため、更新をダウンロードできません", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index fd666f8c..fd1fd5d6 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -2877,6 +2877,8 @@ "app.update.backend.error.sha256_missing_current_package": "SHA256SUMS не содержит пакет обновления для текущей платформы", "app.update.backend.error.sha256sums_download_failed": "Не удалось скачать SHA256SUMS: HTTP {{status}}", "app.update.backend.error.sha256sums_missing": "Release не предоставляет SHA256SUMS", + "app.update.backend.error.install_target_not_writable": "Текущий каталог установки недоступен для записи: {{path}} ({{detail}})", + "app.update.backend.error.install_target_unresolved": "Не удалось определить текущий каталог установки", "app.update.backend.error.update_package_not_found": "Пакет обновления не найден: {{name}}", "app.update.backend.message.app_directory_unavailable": "Нет доступа к каталогу приложения: {{path}}", "app.update.backend.message.app_directory_unresolved_download": "Не удалось определить текущий каталог приложения, поэтому обновление нельзя скачать", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index cd5b403c..871c5ec6 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -2877,6 +2877,8 @@ "app.update.backend.error.sha256_missing_current_package": "SHA256SUMS 未包含当前平台更新包", "app.update.backend.error.sha256sums_download_failed": "下载 SHA256SUMS 失败:HTTP {{status}}", "app.update.backend.error.sha256sums_missing": "Release 未提供 SHA256SUMS", + "app.update.backend.error.install_target_not_writable": "当前安装目录不可写:{{path}}({{detail}})", + "app.update.backend.error.install_target_unresolved": "无法确定当前安装目录", "app.update.backend.error.update_package_not_found": "未找到更新包:{{name}}", "app.update.backend.message.app_directory_unavailable": "无法访问应用目录:{{path}}", "app.update.backend.message.app_directory_unresolved_download": "无法确定当前应用目录,无法下载更新", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 69af5cc2..3e226eb3 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -2877,6 +2877,8 @@ "app.update.backend.error.sha256_missing_current_package": "SHA256SUMS 未包含目前平台更新套件", "app.update.backend.error.sha256sums_download_failed": "下載 SHA256SUMS 失敗:HTTP {{status}}", "app.update.backend.error.sha256sums_missing": "Release 未提供 SHA256SUMS", + "app.update.backend.error.install_target_not_writable": "目前安裝目錄不可寫:{{path}}({{detail}})", + "app.update.backend.error.install_target_unresolved": "無法確定目前安裝目錄", "app.update.backend.error.update_package_not_found": "未找到更新套件:{{name}}", "app.update.backend.message.app_directory_unavailable": "無法存取應用程式目錄:{{path}}", "app.update.backend.message.app_directory_unresolved_download": "無法判斷目前應用程式目錄,因此無法下載更新",