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() {
} onClick={() => checkForUpdates(false)}>{t('app.about.action.check_updates')},
,
lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded && !isBackgroundProgressForLatestUpdate ? (
- } onClick={() => downloadUpdate(lastUpdateInfo, false)}>{t('app.about.action.download_update')}
+ } onClick={() => downloadUpdate(lastUpdateInfo, false)}>{t('app.about.action.download_update')}
+ ) : null,
+ isLatestUpdateDownloaded ? (
+
) : null,
isLatestUpdateDownloaded ? (
} onClick={handleInstallFromProgress}>
@@ -6006,6 +6012,9 @@ function App() {
] : (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": "無法判斷目前應用程式目錄,因此無法下載更新",