diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index 90060088..140dd266 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -551,7 +551,7 @@ jobs: - name: Test Windows Online Updater (Windows AMD64) if: ${{ matrix.platform == 'windows/amd64' }} shell: pwsh - run: go test ./internal/app -run '^(TestWindowsPowerShellUpdater|TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix|TestShouldWindowsUpdateLaunchDownloadedAssetDirectly|TestResolveUpdateInstallMode|TestExpectedAssetNameForWindowsInstallMode|TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory|TestValidateUpdatePackageForCurrentInstallMode|TestInstallUpdateAndRestartMSI|TestBuildWindowsMSIUpdatePowerShellScript|TestBuildWindowsMSILaunchCommand|TestResolveWindowsMSIExecPath)' -count=1 -timeout=3m + run: go test . ./internal/app -run '^(TestShouldEnableWindowsMSISingleInstanceOnlyForInstalledMainGUI|TestPrimaryWindowActivatorQueuesRequestsUntilRuntimeStartup|TestAcquireWindowsMSISingleInstance|TestResolveWindowsUpdateMaintenanceName|TestAcquireWindowsUpdateMaintenance|TestPrepareWindowsUpdateHandoff|TestWindowsPowerShellUpdater|TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix|TestShouldWindowsUpdateLaunchDownloadedAssetDirectly|TestResolveUpdateInstallMode|TestExpectedAssetNameForWindowsInstallMode|TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory|TestValidateUpdatePackageForCurrentInstallMode|TestWindowsUpdateRequiresExplicitCloseConfirmation|TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows|TestInstallUpdateAndRestartMSI|TestFindOtherWindowsUpdateInstances|TestCloseWindowsUpdateInstances|TestInstallUpdateAndRestartClosesOtherTargetInstances|TestBuildWindowsMSIUpdatePowerShellScript|TestBuildWindowsMSILaunchCommand|TestResolveWindowsMSIExecPath)' -count=1 -timeout=3m # ---- 生成 dev 版本号 ---- - name: Generate Dev Version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e7df92b..472c9506 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -508,7 +508,7 @@ jobs: - name: Test Windows Online Updater (Windows AMD64) if: ${{ matrix.platform == 'windows/amd64' }} shell: pwsh - run: go test ./internal/app -run '^(TestWindowsPowerShellUpdater|TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix|TestShouldWindowsUpdateLaunchDownloadedAssetDirectly|TestResolveUpdateInstallMode|TestExpectedAssetNameForWindowsInstallMode|TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory|TestValidateUpdatePackageForCurrentInstallMode|TestInstallUpdateAndRestartMSI|TestBuildWindowsMSIUpdatePowerShellScript|TestBuildWindowsMSILaunchCommand|TestResolveWindowsMSIExecPath)' -count=1 -timeout=3m + run: go test . ./internal/app -run '^(TestShouldEnableWindowsMSISingleInstanceOnlyForInstalledMainGUI|TestPrimaryWindowActivatorQueuesRequestsUntilRuntimeStartup|TestAcquireWindowsMSISingleInstance|TestResolveWindowsUpdateMaintenanceName|TestAcquireWindowsUpdateMaintenance|TestPrepareWindowsUpdateHandoff|TestWindowsPowerShellUpdater|TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix|TestShouldWindowsUpdateLaunchDownloadedAssetDirectly|TestResolveUpdateInstallMode|TestExpectedAssetNameForWindowsInstallMode|TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory|TestValidateUpdatePackageForCurrentInstallMode|TestWindowsUpdateRequiresExplicitCloseConfirmation|TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows|TestInstallUpdateAndRestartMSI|TestFindOtherWindowsUpdateInstances|TestCloseWindowsUpdateInstances|TestInstallUpdateAndRestartClosesOtherTargetInstances|TestBuildWindowsMSIUpdatePowerShellScript|TestBuildWindowsMSILaunchCommand|TestResolveWindowsMSIExecPath)' -count=1 -timeout=3m - name: Build shell: bash diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d2ce098..5d136405 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2568,8 +2568,23 @@ function App() { }, [forceQuitApplication, resetApplicationQuitRequest, saveQuery, savedQueries, t, tabs]); const handleInstallUpdateRequest = useCallback(async () => { - await handleApplicationQuitRequest(handleInstallFromProgress); - }, [handleApplicationQuitRequest, handleInstallFromProgress]); + if (installMode === 'portable' || installMode === 'msi') { + Modal.confirm({ + title: t('app.about.update_install_confirm.close_instances_title'), + content: t('app.about.update_install_confirm.close_instances_content'), + okText: t('app.about.update_install_confirm.close_instances_ok'), + cancelText: t('common.cancel'), + closable: true, + maskClosable: false, + okButtonProps: { danger: true, type: 'primary' }, + onOk: async () => { + await handleApplicationQuitRequest(() => handleInstallFromProgress(true)); + }, + }); + return; + } + await handleApplicationQuitRequest(() => handleInstallFromProgress(false)); + }, [handleApplicationQuitRequest, handleInstallFromProgress, installMode, t]); useEffect(() => { const offBeforeClose = EventsOn('app:before-close-request', () => { diff --git a/frontend/src/App.update-quit-guard.test.ts b/frontend/src/App.update-quit-guard.test.ts index e6ce435e..1d6c7504 100644 --- a/frontend/src/App.update-quit-guard.test.ts +++ b/frontend/src/App.update-quit-guard.test.ts @@ -28,12 +28,17 @@ describe('restart-to-update unsaved SQL guard', () => { expect(actionAfterSaveIndex).toBeGreaterThan(saveIndex); }); - it('uses a no-argument wrapper for both restart-to-update buttons', () => { + it('confirms closing every Windows instance before entering the unsaved SQL guard', () => { expect(appSource).toContain('const handleInstallUpdateRequest = useCallback(async () => {'); - expect(appSource).toContain('await handleApplicationQuitRequest(handleInstallFromProgress);'); + expect(appSource).toContain("if (installMode === 'portable' || installMode === 'msi') {"); + expect(appSource).toContain("title: t('app.about.update_install_confirm.close_instances_title')"); + expect(appSource).toContain("content: t('app.about.update_install_confirm.close_instances_content')"); + expect(appSource).toContain("okText: t('app.about.update_install_confirm.close_instances_ok')"); + expect(appSource).toContain("cancelText: t('common.cancel')"); + expect(appSource).toContain('await handleApplicationQuitRequest(() => handleInstallFromProgress(true));'); + expect(appSource).toContain('await handleApplicationQuitRequest(() => handleInstallFromProgress(false));'); expect(appSource.match(/void handleInstallUpdateRequest\(\);/g)).toHaveLength(2); expect(appSource).not.toContain('onClick={handleInstallFromProgress}'); - expect(appSource).not.toContain('handleApplicationQuitRequest(handleInstallFromProgress('); expect(appSource).toContain("updateInstallAction === 'install-and-restart'"); expect(appSource).toContain("updateInstallAction === 'launch-installer'"); expect(appSource.match(/\{updateInstallActionLabel\}/g)).toHaveLength(2); diff --git a/frontend/src/hooks/useAppUpdateManager.test.tsx b/frontend/src/hooks/useAppUpdateManager.test.tsx index e609a646..8c107d66 100644 --- a/frontend/src/hooks/useAppUpdateManager.test.tsx +++ b/frontend/src/hooks/useAppUpdateManager.test.tsx @@ -297,10 +297,11 @@ describe('useAppUpdateManager', () => { await hook?.checkForUpdates(false); }); await act(async () => { - await hook?.handleInstallFromProgress(); + await hook?.handleInstallFromProgress(true); }); expect(backendApp.InstallUpdateAndRestart).toHaveBeenCalledTimes(1); + expect(backendApp.InstallUpdateAndRestart).toHaveBeenCalledWith(true); expect(hook?.updateInstallAction).toBe('launch-installer'); expect(hook?.updateDownloadProgress.message).toBe('app.about.download_progress.installer_started'); }); @@ -330,6 +331,7 @@ describe('useAppUpdateManager', () => { }); expect(backendApp.InstallUpdateAndRestart).toHaveBeenCalledTimes(1); + expect(backendApp.InstallUpdateAndRestart).toHaveBeenCalledWith(false); expect(backendApp.OpenDownloadedUpdateDirectory).not.toHaveBeenCalled(); }); diff --git a/frontend/src/hooks/useAppUpdateManager.ts b/frontend/src/hooks/useAppUpdateManager.ts index 385fd9d8..b6f27303 100644 --- a/frontend/src/hooks/useAppUpdateManager.ts +++ b/frontend/src/hooks/useAppUpdateManager.ts @@ -384,7 +384,7 @@ export const useAppUpdateManager = ({ const canShowProgressEntry = (isLatestUpdateDownloaded || isBackgroundProgressForLatestUpdate) && updateInstallTriggeredVersionRef.current !== (lastUpdateKey || null); - const handleInstallFromProgress = useCallback(async (): Promise => { + const handleInstallFromProgress = useCallback(async (closeAllWindowsInstancesConfirmed = false): Promise => { const canInstall = updateDownloadProgress.status === 'done' || (Boolean(lastUpdateInfo?.hasUpdate) && (Boolean(lastUpdateInfo?.downloaded) || updateDownloadedVersionRef.current === lastUpdateKey)); if (!canInstall) { @@ -404,7 +404,7 @@ export const useAppUpdateManager = ({ })); let res: any = null; try { - res = await (window as any).go?.app?.App?.InstallUpdateAndRestart?.(); + res = await (window as any).go?.app?.App?.InstallUpdateAndRestart?.(closeAllWindowsInstancesConfirmed); } catch (error: any) { res = { success: false, message: error?.message || t('common.unknown') }; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d8ba446f..a16eb5b2 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -569,7 +569,7 @@ if ( RenameSQLDirectory: async (directoryPath: string, name: string) => ({ success: true, data: { directoryPath: `${directoryPath.replace(/[\\/][^\\/]*$/, '')}/${name}`, name } }), WriteSQLFile: async (_filePath: string, _content: string) => ({ success: true }), ExportSQLFile: async (_defaultName: string, _content: string) => ({ success: false, message: t('app.browser_mock.export_sql_unsupported') }), - InstallUpdateAndRestart: async () => ({ success: false }), + InstallUpdateAndRestart: async (_closeAllWindowsInstancesConfirmed: boolean) => ({ success: false }), ImportConfigFile: async () => ({ success: false, message: '已取消' }), ImportConnectionsPayload: async (raw: string, _password?: string) => { try { diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index e461aee0..6d3f8a0b 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -222,7 +222,7 @@ export function ImportSavedQueries(arg1:connection.SavedQueryImportPayload):Prom export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise; -export function InstallUpdateAndRestart():Promise; +export function InstallUpdateAndRestart(arg1:boolean):Promise; export function JVMApplyChange(arg1:connection.ConnectionConfig,arg2:jvm.ChangeRequest):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 8c629e13..cd1ba4f8 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -430,8 +430,8 @@ export function InstallLocalDriverPackage(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3, arg4); } -export function InstallUpdateAndRestart() { - return window['go']['app']['App']['InstallUpdateAndRestart'](); +export function InstallUpdateAndRestart(arg1) { + return window['go']['app']['App']['InstallUpdateAndRestart'](arg1); } export function JVMApplyChange(arg1, arg2) { diff --git a/internal/app/application_quit_test.go b/internal/app/application_quit_test.go index 66c2c37b..7fd97926 100644 --- a/internal/app/application_quit_test.go +++ b/internal/app/application_quit_test.go @@ -80,6 +80,7 @@ func TestInstallUpdateAndRestartAllowsGuardedCloseBeforeFallbackExit(t *testing. originalQuit := quitApplicationRuntime originalResolveInstallTarget := updateResolveInstallTarget originalLaunchInstallScript := updateLaunchInstallScript + originalAcquireMaintenance := updateAcquireWindowsMaintenance originalSleep := updateQuitSleep originalExit := updateExitProcess t.Cleanup(func() { @@ -87,6 +88,7 @@ func TestInstallUpdateAndRestartAllowsGuardedCloseBeforeFallbackExit(t *testing. quitApplicationRuntime = originalQuit updateResolveInstallTarget = originalResolveInstallTarget updateLaunchInstallScript = originalLaunchInstallScript + updateAcquireWindowsMaintenance = originalAcquireMaintenance updateQuitSleep = originalSleep updateExitProcess = originalExit }) @@ -121,6 +123,9 @@ func TestInstallUpdateAndRestartAllowsGuardedCloseBeforeFallbackExit(t *testing. events <- "installer" return nil } + updateAcquireWindowsMaintenance = func(string) (windowsUpdateMaintenanceLease, error) { + return windowsUpdateMaintenanceLease{Name: `Global\GoNavi-Update-Test`}, nil + } updateQuitSleep = func(duration time.Duration) { sleepDurations <- duration } @@ -129,7 +134,7 @@ func TestInstallUpdateAndRestartAllowsGuardedCloseBeforeFallbackExit(t *testing. exitCodes <- code } - result := app.InstallUpdateAndRestart() + result := app.InstallUpdateAndRestart(true) if !result.Success { t.Fatalf("expected update installation to start, got %#v", result) } diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index 66a4e5af..3891b2fb 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -55,6 +55,8 @@ var ( updateResolveInstallMode = resolveCurrentUpdateInstallMode updateLaunchInstallScript = launchUpdateScript updateFindOtherWindowsInstances = findOtherWindowsUpdateInstances + updateCloseWindowsInstances = closeWindowsUpdateInstances + updateAcquireWindowsMaintenance = acquireWindowsUpdateMaintenance updateQuitSleep = time.Sleep updateExitProcess = os.Exit ) @@ -115,15 +117,17 @@ type updateDownloadProgressPayload struct { } type stagedUpdate struct { - Channel updateChannel - Version string - AssetName string - FilePath string - StagedDir string - InstallLogPath string - InstallMode updateInstallMode - PackageType updatePackageType - AutoRelaunch bool + Channel updateChannel + Version string + AssetName string + FilePath string + StagedDir string + InstallLogPath string + InstallMode updateInstallMode + PackageType updatePackageType + AutoRelaunch bool + MaintenanceEventName string + UpdateHandoffEventName string } type updatePathCandidate struct { @@ -286,7 +290,7 @@ func (a *App) DownloadUpdate() connection.QueryResult { return result } -func (a *App) InstallUpdateAndRestart() connection.QueryResult { +func (a *App) InstallUpdateAndRestart(closeAllWindowsInstancesConfirmed bool) connection.QueryResult { a.updateMu.Lock() staged := a.updateState.staged if staged != nil && strings.TrimSpace(staged.InstallLogPath) == "" { @@ -304,9 +308,35 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult { }), } } + if windowsUpdateCloseConfirmationRequired(stdRuntime.GOOS, closeAllWindowsInstancesConfirmed) { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.update.backend.message.install_launch_failed", map[string]any{ + "detail": a.appText("app.update.backend.error.close_instances_confirmation_required", nil), + }), + Data: map[string]any{ + "requiresCloseConfirmation": true, + }, + } + } if stdRuntime.GOOS == "windows" { installTarget := updateResolveInstallTarget() + maintenanceLease, err := updateAcquireWindowsMaintenance(installTarget) + if err != nil { + return connection.QueryResult{ + Success: false, + Message: a.appText("app.update.backend.message.install_launch_failed", map[string]any{ + "detail": a.appText("app.update.backend.error.maintenance_lock_failed", map[string]any{"detail": err.Error()}), + }), + } + } + defer func() { + if maintenanceLease.Release != nil { + maintenanceLease.Release() + } + }() + staged.MaintenanceEventName = maintenanceLease.Name if staged.InstallMode == updateInstallModePortable { if err := ensureWindowsUpdateTargetWritable(installTarget); err != nil { return connection.QueryResult{ @@ -319,28 +349,22 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult { } finalTarget := resolveWindowsUpdateFinalTargetPath(installTarget, staged.FilePath) - otherInstances, err := updateFindOtherWindowsInstances([]string{installTarget, finalTarget}, os.Getpid()) + closedPIDs, err := closeOtherWindowsUpdateInstancesForInstall([]string{installTarget, finalTarget}, os.Getpid()) if err != nil { + logger.Warnf("关闭 Windows 更新相关实例失败 current=%s target=%s pids=%v error=%v", installTarget, finalTarget, closedPIDs, err) return connection.QueryResult{ Success: false, Message: a.appText("app.update.backend.message.install_launch_failed", map[string]any{ - "detail": err.Error(), - }), - } - } - if len(otherInstances) > 0 { - runningPIDs := otherWindowsUpdateProcessIDs(otherInstances) - logger.Warnf("阻止 Windows 更新:检测到其他实例 current=%s target=%s pids=%v", installTarget, finalTarget, runningPIDs) - return connection.QueryResult{ - Success: false, - Message: a.appText("app.update.backend.message.install_launch_failed", map[string]any{ - "detail": a.appText("app.update.backend.error.other_instances_running", nil), + "detail": a.appText("app.update.backend.error.close_instances_failed", map[string]any{"detail": err.Error()}), }), Data: map[string]any{ - "runningPids": runningPIDs, + "runningPids": closedPIDs, }, } } + if len(closedPIDs) > 0 { + logger.Infof("Windows 更新已关闭其他 GoNavi 实例 current=%s target=%s pids=%v", installTarget, finalTarget, closedPIDs) + } } if err := updateLaunchInstallScript(staged); err != nil { @@ -364,7 +388,6 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult { }, } } - go a.quitForUpdate() msg := a.appText("app.update.backend.message.install_started", nil) @@ -1721,10 +1744,19 @@ func launchUpdateScript(staged *stagedUpdate) error { } func launchWindowsUpdate(staged *stagedUpdate, targetExe string, pid int) error { - if staged != nil && staged.InstallMode == updateInstallModeMSI && staged.PackageType == updatePackageTypeMSI { - return launchWindowsMSIUpdate(staged, targetExe, pid) + if staged == nil { + return localizedUpdateError{key: "app.update.backend.message.no_downloaded_package"} } - return launchWindowsUpdateWithCleanup(staged, targetExe, pid) + handoff, err := prepareWindowsUpdateHandoff() + if err != nil { + return err + } + defer handoff.Close() + staged.UpdateHandoffEventName = handoff.Name + if staged != nil && staged.InstallMode == updateInstallModeMSI && staged.PackageType == updatePackageTypeMSI { + return launchWindowsMSIUpdate(staged, targetExe, pid, handoff.Wait) + } + return launchWindowsUpdateWithCleanup(staged, targetExe, pid, handoff.Wait) } func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error { @@ -1795,6 +1827,8 @@ func buildWindowsLaunchCommand(scriptPath string, context windowsUpdateLaunchCon "GONAVI_UPDATE_CURRENT_TARGET="+context.CurrentTargetPath, "GONAVI_UPDATE_STAGED_DIR="+context.StagedDir, "GONAVI_UPDATE_LOG_PATH="+context.LogPath, + "GONAVI_UPDATE_MAINTENANCE_EVENT_NAME="+context.MaintenanceEventName, + "GONAVI_UPDATE_HANDOFF_EVENT_NAME="+context.HandoffEventName, "GONAVI_UPDATE_PID="+strconv.Itoa(context.PID), ) configureWindowsUpdateCommand(cmd) diff --git a/internal/app/methods_update_test.go b/internal/app/methods_update_test.go index f9c3ae90..cf98a364 100644 --- a/internal/app/methods_update_test.go +++ b/internal/app/methods_update_test.go @@ -659,7 +659,7 @@ func TestInstallUpdateAndRestartFailsBeforeLaunchWhenWindowsTargetDirIsNotWritab return nil } - result := app.InstallUpdateAndRestart() + result := app.InstallUpdateAndRestart(true) if result.Success { t.Fatalf("expected InstallUpdateAndRestart to fail, got %#v", result) } @@ -711,7 +711,7 @@ func TestInstallUpdateAndRestartMSISkipsPortableTargetWriteProbe(t *testing.T) { return errors.New("stop after MSI launcher reached") } - result := app.InstallUpdateAndRestart() + result := app.InstallUpdateAndRestart(true) if result.Success { t.Fatalf("expected injected launcher error, got %#v", result) } diff --git a/internal/app/methods_update_windows_process_test.go b/internal/app/methods_update_windows_process_test.go index 9e7e5fb3..e1ef2c58 100644 --- a/internal/app/methods_update_windows_process_test.go +++ b/internal/app/methods_update_windows_process_test.go @@ -4,9 +4,11 @@ package app import ( "os" + "os/exec" "path/filepath" "strings" "testing" + "time" ) func TestBuildWindowsLaunchCommandHidesConsoleWindow(t *testing.T) { @@ -44,7 +46,73 @@ func TestFindOtherWindowsUpdateInstancesMatchesExecutablePath(t *testing.T) { t.Fatalf("expected current executable process %d to be detected, got %#v", os.Getpid(), instances) } -func TestInstallUpdateAndRestartBlocksWhenAnotherTargetInstanceIsRunning(t *testing.T) { +func TestCloseWindowsUpdateInstancesTerminatesProcessesWithoutWindows(t *testing.T) { + helperPath := filepath.Join(t.TempDir(), "GoNavi.exe") + build := exec.Command("go", "build", "-ldflags=-H=windowsgui", "-o", helperPath, "./testdata/windows_update_helper") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build update helper: %v\n%s", err, output) + } + command := exec.Command(helperPath) + if err := command.Start(); err != nil { + t.Fatalf("start update helper: %v", err) + } + t.Cleanup(func() { + if command.Process != nil { + _ = command.Process.Kill() + } + }) + time.Sleep(150 * time.Millisecond) + + process := windowsUpdateProcess{PID: uint32(command.Process.Pid), Executable: helperPath} + if err := closeWindowsUpdateInstances([]windowsUpdateProcess{process}); err != nil { + t.Fatalf("closeWindowsUpdateInstances returned error: %v", err) + } + _ = command.Wait() + instances, err := findOtherWindowsUpdateInstances([]string{helperPath}, -1) + if err != nil { + t.Fatalf("findOtherWindowsUpdateInstances after close: %v", err) + } + if len(instances) != 0 { + t.Fatalf("helper still running after close: %#v", instances) + } +} + +func TestCloseWindowsUpdateInstancesRejectsChangedExecutableIdentity(t *testing.T) { + helperPath := filepath.Join(t.TempDir(), "GoNavi.exe") + build := exec.Command("go", "build", "-ldflags=-H=windowsgui", "-o", helperPath, "./testdata/windows_update_helper") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build update helper: %v\n%s", err, output) + } + command := exec.Command(helperPath) + if err := command.Start(); err != nil { + t.Fatalf("start update helper: %v", err) + } + t.Cleanup(func() { + if command.Process != nil { + _ = command.Process.Kill() + _, _ = command.Process.Wait() + } + }) + time.Sleep(150 * time.Millisecond) + + staleIdentity := windowsUpdateProcess{ + PID: uint32(command.Process.Pid), + Executable: filepath.Join(filepath.Dir(helperPath), "Different.exe"), + } + err := closeWindowsUpdateInstances([]windowsUpdateProcess{staleIdentity}) + if err == nil || !strings.Contains(err.Error(), "executable changed") { + t.Fatalf("identity mismatch error = %v, want executable changed", err) + } + instances, findErr := findOtherWindowsUpdateInstances([]string{helperPath}, -1) + if findErr != nil { + t.Fatalf("find helper after rejected close: %v", findErr) + } + if len(instances) != 1 || instances[0].PID != uint32(command.Process.Pid) { + t.Fatalf("helper was not preserved after identity mismatch: %#v", instances) + } +} + +func TestInstallUpdateAndRestartClosesOtherTargetInstances(t *testing.T) { dir := t.TempDir() currentTarget := filepath.Join(dir, "GoNavi-dev-old-Windows-Amd64-Portable.exe") newTarget := filepath.Join(dir, "GoNavi-dev-new-Windows-Amd64-Portable.exe") @@ -67,39 +135,115 @@ func TestInstallUpdateAndRestartBlocksWhenAnotherTargetInstanceIsRunning(t *test originalResolveInstallTarget := updateResolveInstallTarget originalFindOtherInstances := updateFindOtherWindowsInstances + originalCloseInstances := updateCloseWindowsInstances + originalAcquireMaintenance := updateAcquireWindowsMaintenance originalLaunchInstallScript := updateLaunchInstallScript + originalQuitSleep := updateQuitSleep + originalExitProcess := updateExitProcess t.Cleanup(func() { updateResolveInstallTarget = originalResolveInstallTarget updateFindOtherWindowsInstances = originalFindOtherInstances + updateCloseWindowsInstances = originalCloseInstances + updateAcquireWindowsMaintenance = originalAcquireMaintenance updateLaunchInstallScript = originalLaunchInstallScript + updateQuitSleep = originalQuitSleep + updateExitProcess = originalExitProcess }) updateResolveInstallTarget = func() string { return currentTarget } + updateAcquireWindowsMaintenance = func(string) (windowsUpdateMaintenanceLease, error) { + return windowsUpdateMaintenanceLease{Name: `Global\GoNavi-Update-Test`}, nil + } var checkedTargets []string + findCalls := 0 updateFindOtherWindowsInstances = func(targets []string, currentPID int) ([]windowsUpdateProcess, error) { + findCalls++ checkedTargets = append([]string(nil), targets...) if currentPID != os.Getpid() { t.Fatalf("current PID = %d, want %d", currentPID, os.Getpid()) } - return []windowsUpdateProcess{{PID: 4321, Executable: newTarget}}, nil + if findCalls == 1 { + return []windowsUpdateProcess{{PID: 4321, Executable: newTarget}}, nil + } + return nil, nil + } + closed := false + updateCloseWindowsInstances = func(processes []windowsUpdateProcess) error { + closed = len(processes) == 1 && processes[0].PID == 4321 && processes[0].Executable == newTarget + return nil } launched := false updateLaunchInstallScript = func(*stagedUpdate) error { launched = true return nil } + quitFinished := make(chan struct{}, 1) + updateQuitSleep = func(time.Duration) {} + updateExitProcess = func(int) { quitFinished <- struct{}{} } - result := app.InstallUpdateAndRestart() - if result.Success { - t.Fatalf("expected another running instance to block update, got %#v", result) + result := app.InstallUpdateAndRestart(true) + if !result.Success { + t.Fatalf("expected confirmed update to close other instances and launch, got %#v", result) } - if launched { - t.Fatal("update launcher must not start while another target instance is running") + if !closed { + t.Fatal("confirmed update did not close the discovered GoNavi instance") + } + if !launched { + t.Fatal("update launcher did not start after other instances closed") } if len(checkedTargets) != 2 || checkedTargets[0] != currentTarget || checkedTargets[1] != newTarget { t.Fatalf("checked targets = %#v, want current and final target", checkedTargets) } - if !strings.Contains(result.Message, "GoNavi instance") { - t.Fatalf("expected actionable other-instance message, got %q", result.Message) + if findCalls != 2 { + t.Fatalf("find calls = %d, want discovery and post-close verification", findCalls) + } + select { + case <-quitFinished: + case <-time.After(time.Second): + t.Fatal("timed out waiting for updater-controlled quit goroutine") + } +} + +func TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows(t *testing.T) { + dir := t.TempDir() + packagePath := filepath.Join(dir, "GoNavi-Installer.msi") + if err := os.WriteFile(packagePath, []byte("fake msi"), 0o644); err != nil { + t.Fatalf("WriteFile MSI: %v", err) + } + app := NewApp() + app.SetLanguage("en-US") + app.updateState.staged = &stagedUpdate{ + Version: "1.2.3", + AssetName: filepath.Base(packagePath), + FilePath: packagePath, + StagedDir: dir, + InstallMode: updateInstallModeMSI, + PackageType: updatePackageTypeMSI, + AutoRelaunch: true, + InstallLogPath: filepath.Join(dir, "update.log"), + } + + originalResolveMode := updateResolveInstallMode + originalLaunch := updateLaunchInstallScript + t.Cleanup(func() { + updateResolveInstallMode = originalResolveMode + updateLaunchInstallScript = originalLaunch + }) + updateResolveInstallMode = func() updateInstallMode { return updateInstallModeMSI } + launched := false + updateLaunchInstallScript = func(*stagedUpdate) error { + launched = true + return nil + } + + result := app.InstallUpdateAndRestart(false) + if result.Success { + t.Fatalf("update without close confirmation unexpectedly succeeded: %#v", result) + } + if launched { + t.Fatal("update launcher must not start before close-all confirmation") + } + if !strings.Contains(result.Message, "current GoNavi installation") { + t.Fatalf("missing close confirmation message: %q", result.Message) } } diff --git a/internal/app/methods_update_windows_script_test.go b/internal/app/methods_update_windows_script_test.go index ed21f400..b2dd9e91 100644 --- a/internal/app/methods_update_windows_script_test.go +++ b/internal/app/methods_update_windows_script_test.go @@ -36,6 +36,10 @@ func TestBuildWindowsPowerShellScriptWaitsRetriesAndRollsBack(t *testing.T) { `previous executable backup is missing`, `Restore-PreviousTarget`, `if ($NewProcess.HasExited)`, + `function Release-UpdateMaintenanceLock`, + `[Threading.EventWaitHandle]::OpenExisting($MaintenanceEventName)`, + `[void]$HandoffEvent.Set()`, + `update maintenance lock could not be released before relaunch`, `package kept for manual install`, `previous application relaunched after update failure`, } @@ -108,12 +112,14 @@ func TestBuildWindowsPowerShellScriptAvoidsCmdParsing(t *testing.T) { func TestBuildWindowsLaunchCommandUsesHiddenPowerShellFile(t *testing.T) { context := windowsUpdateLaunchContext{ - SourcePath: `C:\tmp\GoNavi-0.8.5-Windows-Amd64.exe`, - TargetPath: `C:\GoNavi\GoNavi.exe`, - CurrentTargetPath: `C:\GoNavi\GoNavi.exe`, - StagedDir: `C:\tmp\gonavi-update`, - LogPath: `C:\tmp\gonavi-update\update.log`, - PID: 12345, + SourcePath: `C:\tmp\GoNavi-0.8.5-Windows-Amd64.exe`, + TargetPath: `C:\GoNavi\GoNavi.exe`, + CurrentTargetPath: `C:\GoNavi\GoNavi.exe`, + StagedDir: `C:\tmp\gonavi-update`, + LogPath: `C:\tmp\gonavi-update\update.log`, + MaintenanceEventName: `Global\GoNavi-Update-Test`, + HandoffEventName: `Local\GoNavi-Update-Handoff-Test`, + PID: 12345, } scriptPath := `C:\tmp\gonavi-update\update.ps1` cmd := buildWindowsLaunchCommand(scriptPath, context) diff --git a/internal/app/update_cleanup.go b/internal/app/update_cleanup.go index 4ffd373c..0b3a2443 100644 --- a/internal/app/update_cleanup.go +++ b/internal/app/update_cleanup.go @@ -11,7 +11,7 @@ import ( "GoNavi-Wails/internal/logger" ) -func launchWindowsMSIUpdate(staged *stagedUpdate, targetExe string, pid int) error { +func launchWindowsMSIUpdate(staged *stagedUpdate, targetExe string, pid int, waitForHandoff func() error) error { if staged == nil { return localizedUpdateError{key: "app.update.backend.message.no_downloaded_package"} } @@ -48,19 +48,33 @@ func launchWindowsMSIUpdate(staged *stagedUpdate, targetExe string, pid int) err } msiExecPath := resolveWindowsMSIExecPath(os.Getenv) context := windowsMSIUpdateLaunchContext{ - SourcePath: staged.FilePath, - TargetPath: strings.TrimSpace(targetExe), - StagedDir: staged.StagedDir, - LogPath: staged.InstallLogPath, - MSILogPath: msiLogPath, - MSIExecPath: msiExecPath, - PID: pid, + SourcePath: staged.FilePath, + TargetPath: strings.TrimSpace(targetExe), + StagedDir: staged.StagedDir, + LogPath: staged.InstallLogPath, + MSILogPath: msiLogPath, + MSIExecPath: msiExecPath, + MaintenanceEventName: staged.MaintenanceEventName, + HandoffEventName: staged.UpdateHandoffEventName, + PID: pid, } logger.Infof("启动 Windows MSI 更新器:target=%s script=%s log=%s msi_log=%s package=%s", targetExe, scriptPath, staged.InstallLogPath, msiLogPath, staged.FilePath) cmd := buildWindowsMSILaunchCommand(scriptPath, context) if err := cmd.Start(); err != nil { return fmt.Errorf("start Windows MSI updater: %w", err) } + if waitForHandoff != nil { + if err := waitForHandoff(); err != nil { + if cmd.Process != nil { + if killErr := cmd.Process.Kill(); killErr == nil { + _, _ = cmd.Process.Wait() + } else { + _ = cmd.Process.Release() + } + } + return err + } + } if cmd.Process != nil { if err := cmd.Process.Release(); err != nil { logger.Warnf("释放 Windows MSI 更新脚本进程句柄失败:%v", err) @@ -81,7 +95,7 @@ func resolveWindowsMSIExecPath(getenv func(string) string) string { return filepath.Join(`C:\Windows`, "System32", "msiexec.exe") } -func launchWindowsUpdateWithCleanup(staged *stagedUpdate, targetExe string, pid int) error { +func launchWindowsUpdateWithCleanup(staged *stagedUpdate, targetExe string, pid int, waitForHandoff func() error) error { if staged == nil { return localizedUpdateError{key: "app.update.backend.message.no_downloaded_package"} } @@ -118,18 +132,32 @@ func launchWindowsUpdateWithCleanup(staged *stagedUpdate, targetExe string, pid } launchContext := windowsUpdateLaunchContext{ - SourcePath: staged.FilePath, - TargetPath: finalTargetExe, - CurrentTargetPath: currentTargetExe, - StagedDir: staged.StagedDir, - LogPath: staged.InstallLogPath, - PID: pid, + SourcePath: staged.FilePath, + TargetPath: finalTargetExe, + CurrentTargetPath: currentTargetExe, + StagedDir: staged.StagedDir, + LogPath: staged.InstallLogPath, + MaintenanceEventName: staged.MaintenanceEventName, + HandoffEventName: staged.UpdateHandoffEventName, + PID: pid, } logger.Infof("启动 Windows PowerShell 更新器:current=%s target=%s script=%s log=%s", currentTargetExe, finalTargetExe, scriptPath, staged.InstallLogPath) cmd := buildWindowsLaunchCommand(scriptPath, launchContext) if err := cmd.Start(); err != nil { return err } + if waitForHandoff != nil { + if err := waitForHandoff(); err != nil { + if cmd.Process != nil { + if killErr := cmd.Process.Kill(); killErr == nil { + _, _ = cmd.Process.Wait() + } else { + _ = cmd.Process.Release() + } + } + return err + } + } if cmd.Process != nil { if err := cmd.Process.Release(); err != nil { logger.Warnf("释放 Windows 更新脚本进程句柄失败:%v", err) diff --git a/internal/app/update_cleanup_test.go b/internal/app/update_cleanup_test.go index 06f17d4d..3a8c788f 100644 --- a/internal/app/update_cleanup_test.go +++ b/internal/app/update_cleanup_test.go @@ -181,10 +181,14 @@ func TestBuildWindowsPowerShellScriptRelaunchesBeforeDeletingFallbacks(t *testin script := buildWindowsPowerShellScript() startIdx := strings.Index(script, `$NewProcess = Start-Process -FilePath $Target`) + releaseIdx := strings.Index(script, `if (-not (Release-UpdateMaintenanceLock))`) deleteCurrentIdx := strings.Index(script, `Remove-UpdateArtifact $CurrentTarget`) deleteSourceIdx := strings.Index(script, `Remove-UpdateArtifact $Source`) - if startIdx < 0 || deleteCurrentIdx < 0 || deleteSourceIdx < 0 { - t.Fatalf("expected relaunch and cleanup commands in script (start=%d current=%d source=%d)\n%s", startIdx, deleteCurrentIdx, deleteSourceIdx, script) + if releaseIdx < 0 || startIdx < 0 || deleteCurrentIdx < 0 || deleteSourceIdx < 0 { + t.Fatalf("expected maintenance release, relaunch and cleanup commands in script (release=%d start=%d current=%d source=%d)\n%s", releaseIdx, startIdx, deleteCurrentIdx, deleteSourceIdx, script) + } + if releaseIdx > startIdx { + t.Fatalf("maintenance lock must be released immediately before relaunch (release=%d start=%d)\n%s", releaseIdx, startIdx, script) } if deleteCurrentIdx < startIdx || deleteSourceIdx < startIdx { t.Fatalf("fallback files must be deleted only after relaunch (start=%d current=%d source=%d)\n%s", startIdx, deleteCurrentIdx, deleteSourceIdx, script) @@ -193,22 +197,26 @@ func TestBuildWindowsPowerShellScriptRelaunchesBeforeDeletingFallbacks(t *testin func TestBuildWindowsLaunchCommandPreservesSpecialPathsInEnvironment(t *testing.T) { context := windowsUpdateLaunchContext{ - SourcePath: `C:\Users\tester\AppData\Local\Temp\GoNavi %TEMP%\GoNavi-0.8.5-Windows-Amd64.exe`, - TargetPath: `D:\软件 ! 100% & (便携版)\O'Brien\GoNavi.exe`, - CurrentTargetPath: `D:\软件 ! 100% & (便携版)\O'Brien\GoNavi-dev-f930ffe.exe`, - StagedDir: `C:\Users\tester\AppData\Local\Temp\GoNavi %TEMP%\stage`, - LogPath: `C:\Users\tester\AppData\Local\Temp\GoNavi %TEMP%\stage\update.log`, - PID: 12345, + SourcePath: `C:\Users\tester\AppData\Local\Temp\GoNavi %TEMP%\GoNavi-0.8.5-Windows-Amd64.exe`, + TargetPath: `D:\软件 ! 100% & (便携版)\O'Brien\GoNavi.exe`, + CurrentTargetPath: `D:\软件 ! 100% & (便携版)\O'Brien\GoNavi-dev-f930ffe.exe`, + StagedDir: `C:\Users\tester\AppData\Local\Temp\GoNavi %TEMP%\stage`, + LogPath: `C:\Users\tester\AppData\Local\Temp\GoNavi %TEMP%\stage\update.log`, + MaintenanceEventName: `Global\GoNavi-Update-Test`, + HandoffEventName: `Local\GoNavi-Update-Handoff-Test`, + PID: 12345, } cmd := buildWindowsLaunchCommand(filepath.Join(context.StagedDir, "update.ps1"), context) wantEnvironment := map[string]string{ - "GONAVI_UPDATE_SOURCE": context.SourcePath, - "GONAVI_UPDATE_TARGET": context.TargetPath, - "GONAVI_UPDATE_CURRENT_TARGET": context.CurrentTargetPath, - "GONAVI_UPDATE_STAGED_DIR": context.StagedDir, - "GONAVI_UPDATE_LOG_PATH": context.LogPath, - "GONAVI_UPDATE_PID": "12345", + "GONAVI_UPDATE_SOURCE": context.SourcePath, + "GONAVI_UPDATE_TARGET": context.TargetPath, + "GONAVI_UPDATE_CURRENT_TARGET": context.CurrentTargetPath, + "GONAVI_UPDATE_STAGED_DIR": context.StagedDir, + "GONAVI_UPDATE_LOG_PATH": context.LogPath, + "GONAVI_UPDATE_MAINTENANCE_EVENT_NAME": context.MaintenanceEventName, + "GONAVI_UPDATE_HANDOFF_EVENT_NAME": context.HandoffEventName, + "GONAVI_UPDATE_PID": "12345", } gotEnvironment := make(map[string]string, len(wantEnvironment)) for _, item := range cmd.Env { diff --git a/internal/app/windows_msi_update.ps1 b/internal/app/windows_msi_update.ps1 index 85066af3..d1a9eb14 100644 --- a/internal/app/windows_msi_update.ps1 +++ b/internal/app/windows_msi_update.ps1 @@ -6,6 +6,8 @@ $StagedDir = $env:GONAVI_UPDATE_STAGED_DIR $LogPath = $env:GONAVI_UPDATE_LOG_PATH $MSILogPath = $env:GONAVI_UPDATE_MSI_LOG_PATH $MSIExecPath = $env:GONAVI_UPDATE_MSIEXEC_PATH +$MaintenanceEventName = $env:GONAVI_UPDATE_MAINTENANCE_EVENT_NAME +$HandoffEventName = $env:GONAVI_UPDATE_HANDOFF_EVENT_NAME $HostProcessId = 0 $HostExited = $false $InstallSucceeded = $false @@ -13,6 +15,9 @@ $LaunchSucceeded = $false $DesktopShortcutDirectories = @() $DesktopShortcutState = $null $DesktopShortcutInstallValue = '1' +$MaintenanceEvent = $null +$HandoffEvent = $null +$MaintenanceLockReleased = $false function Write-UpdateLog { param([string]$Message) @@ -27,6 +32,25 @@ function Write-UpdateLog { # Logging must never hide the original updater error. } } + +function Release-UpdateMaintenanceLock { + if ($MaintenanceLockReleased) { + return $true + } + try { + if ($null -eq $MaintenanceEvent) { + $script:MaintenanceLockReleased = $true + return $true + } + $script:MaintenanceEvent.Dispose() + $script:MaintenanceEvent = $null + $script:MaintenanceLockReleased = $true + return $true + } catch { + Write-UpdateLog ("maintenance lock release failed: " + $_.Exception.Message) + return $false + } +} function Quote-NativeArgument { param([string]$Value) @@ -49,7 +73,7 @@ function Remove-UpdateArtifact { } try { - foreach ($requiredPath in @($Source, $Target, $StagedDir, $LogPath, $MSILogPath, $MSIExecPath)) { + foreach ($requiredPath in @($Source, $Target, $StagedDir, $LogPath, $MSILogPath, $MSIExecPath, $MaintenanceEventName, $HandoffEventName)) { if ([string]::IsNullOrWhiteSpace($requiredPath)) { throw 'missing required MSI updater path' } @@ -72,6 +96,11 @@ try { throw 'target directory does not exist' } + $MaintenanceEvent = [Threading.EventWaitHandle]::OpenExisting($MaintenanceEventName) + $HandoffEvent = [Threading.EventWaitHandle]::OpenExisting($HandoffEventName) + [void]$HandoffEvent.Set() + $HandoffEvent.Dispose() + $HandoffEvent = $null Write-UpdateLog 'MSI updater started' $waitedSeconds = 0 while (Get-Process -Id $HostProcessId -ErrorAction SilentlyContinue) { @@ -123,6 +152,9 @@ try { throw 'desktop shortcut state could not be restored' } [void](Repair-LegacyGoNaviTaskbarPins -TargetPath $Target) + if (-not (Release-UpdateMaintenanceLock)) { + throw 'update maintenance lock could not be released before relaunch' + } Write-UpdateLog ("launching installed application: " + $Target) $NewProcess = Start-Process -FilePath $Target -WorkingDirectory $TargetDir -PassThru -ErrorAction Stop Start-Sleep -Milliseconds 1500 @@ -158,7 +190,8 @@ try { [void](Remove-GoNaviDesktopShortcutsForTarget -TargetPath $Target -DesktopDirectories $DesktopShortcutDirectories) } [void](Restore-GoNaviDesktopShortcutState -State $DesktopShortcutState) - if ($HostExited -and -not $LaunchSucceeded -and (Test-Path -LiteralPath $Target -PathType Leaf)) { + [void](Release-UpdateMaintenanceLock) + if ($HostExited -and -not $LaunchSucceeded -and $MaintenanceLockReleased -and (Test-Path -LiteralPath $Target -PathType Leaf)) { try { $TargetDir = [IO.Path]::GetDirectoryName($Target) Start-Process -FilePath $Target -WorkingDirectory $TargetDir -ErrorAction Stop | Out-Null diff --git a/internal/app/windows_msi_update_script.go b/internal/app/windows_msi_update_script.go index ae29c67c..db9fb6b7 100644 --- a/internal/app/windows_msi_update_script.go +++ b/internal/app/windows_msi_update_script.go @@ -14,13 +14,15 @@ var windowsMSIUpdatePowerShellScript string var windowsShortcutRepairPowerShellScript string type windowsMSIUpdateLaunchContext struct { - SourcePath string - TargetPath string - StagedDir string - LogPath string - MSILogPath string - MSIExecPath string - PID int + SourcePath string + TargetPath string + StagedDir string + LogPath string + MSILogPath string + MSIExecPath string + MaintenanceEventName string + HandoffEventName string + PID int } func buildWindowsMSIUpdatePowerShellScript() string { @@ -47,6 +49,8 @@ func buildWindowsMSILaunchCommand(scriptPath string, context windowsMSIUpdateLau "GONAVI_UPDATE_LOG_PATH="+context.LogPath, "GONAVI_UPDATE_MSI_LOG_PATH="+context.MSILogPath, "GONAVI_UPDATE_MSIEXEC_PATH="+context.MSIExecPath, + "GONAVI_UPDATE_MAINTENANCE_EVENT_NAME="+context.MaintenanceEventName, + "GONAVI_UPDATE_HANDOFF_EVENT_NAME="+context.HandoffEventName, "GONAVI_UPDATE_PID="+strconv.Itoa(context.PID), ) configureWindowsUpdateCommand(cmd) diff --git a/internal/app/windows_msi_update_script_test.go b/internal/app/windows_msi_update_script_test.go index 6f24c35a..e019e32a 100644 --- a/internal/app/windows_msi_update_script_test.go +++ b/internal/app/windows_msi_update_script_test.go @@ -28,6 +28,10 @@ func TestBuildWindowsMSIUpdatePowerShellScriptInstallsRelaunchesAndCleans(t *tes `$InstallerExitCode -notin @(0, 1641, 3010)`, `if (-not (Restore-GoNaviDesktopShortcutState -State $DesktopShortcutState -OnlyForeign))`, `Repair-LegacyGoNaviTaskbarPins -TargetPath $Target`, + `function Release-UpdateMaintenanceLock`, + `[Threading.EventWaitHandle]::OpenExisting($MaintenanceEventName)`, + `[void]$HandoffEvent.Set()`, + `update maintenance lock could not be released before relaunch`, `Start-Process -FilePath $Target -WorkingDirectory $TargetDir`, `Remove-UpdateArtifact $Source`, `MSI package retained for manual install`, @@ -47,10 +51,14 @@ func TestBuildWindowsMSIUpdatePowerShellScriptInstallsRelaunchesAndCleans(t *tes t.Fatalf("desktop shortcut state must be captured before MSI starts\n%s", script) } repairIndex := strings.Index(script, `Repair-LegacyGoNaviTaskbarPins -TargetPath $Target`) + releaseIndex := strings.Index(script, `if (-not (Release-UpdateMaintenanceLock))`) relaunchIndex := strings.Index(script, `Start-Process -FilePath $Target -WorkingDirectory $TargetDir`) if repairIndex < installerIndex || repairIndex > relaunchIndex { t.Fatalf("legacy taskbar pins must be repaired after install and before relaunch\n%s", script) } + if releaseIndex < repairIndex || releaseIndex > relaunchIndex { + t.Fatalf("maintenance lock must be released after install repair and before relaunch\n%s", script) + } for _, r := range script { if r > 0x7f { t.Fatalf("MSI updater must remain ASCII-only, found %q", r) @@ -60,23 +68,27 @@ func TestBuildWindowsMSIUpdatePowerShellScriptInstallsRelaunchesAndCleans(t *tes func TestBuildWindowsMSILaunchCommandPreservesPathsInEnvironment(t *testing.T) { context := windowsMSIUpdateLaunchContext{ - SourcePath: `C:\Users\tester\AppData\Local\GoNavi 100%\GoNavi-Installer.msi`, - TargetPath: `D:\software ! 100% & portable\GoNavi.exe`, - StagedDir: `C:\Users\tester\AppData\Local\GoNavi 100%\stage`, - LogPath: `C:\Users\tester\AppData\Local\GoNavi 100%\stage\update.log`, - MSILogPath: `C:\Users\tester\AppData\Local\GoNavi 100%\stage\msi.log`, - MSIExecPath: `C:\Windows\System32\msiexec.exe`, - PID: 12345, + SourcePath: `C:\Users\tester\AppData\Local\GoNavi 100%\GoNavi-Installer.msi`, + TargetPath: `D:\software ! 100% & portable\GoNavi.exe`, + StagedDir: `C:\Users\tester\AppData\Local\GoNavi 100%\stage`, + LogPath: `C:\Users\tester\AppData\Local\GoNavi 100%\stage\update.log`, + MSILogPath: `C:\Users\tester\AppData\Local\GoNavi 100%\stage\msi.log`, + MSIExecPath: `C:\Windows\System32\msiexec.exe`, + MaintenanceEventName: `Global\GoNavi-Update-Test`, + HandoffEventName: `Local\GoNavi-Update-Handoff-Test`, + PID: 12345, } cmd := buildWindowsMSILaunchCommand(filepath.Join(context.StagedDir, "update-msi.ps1"), context) want := map[string]string{ - "GONAVI_UPDATE_SOURCE": context.SourcePath, - "GONAVI_UPDATE_TARGET": context.TargetPath, - "GONAVI_UPDATE_STAGED_DIR": context.StagedDir, - "GONAVI_UPDATE_LOG_PATH": context.LogPath, - "GONAVI_UPDATE_MSI_LOG_PATH": context.MSILogPath, - "GONAVI_UPDATE_MSIEXEC_PATH": context.MSIExecPath, - "GONAVI_UPDATE_PID": "12345", + "GONAVI_UPDATE_SOURCE": context.SourcePath, + "GONAVI_UPDATE_TARGET": context.TargetPath, + "GONAVI_UPDATE_STAGED_DIR": context.StagedDir, + "GONAVI_UPDATE_LOG_PATH": context.LogPath, + "GONAVI_UPDATE_MSI_LOG_PATH": context.MSILogPath, + "GONAVI_UPDATE_MSIEXEC_PATH": context.MSIExecPath, + "GONAVI_UPDATE_MAINTENANCE_EVENT_NAME": context.MaintenanceEventName, + "GONAVI_UPDATE_HANDOFF_EVENT_NAME": context.HandoffEventName, + "GONAVI_UPDATE_PID": "12345", } got := make(map[string]string, len(want)) for _, item := range cmd.Env { diff --git a/internal/app/windows_update.ps1 b/internal/app/windows_update.ps1 index 659b231e..5aa2312c 100644 --- a/internal/app/windows_update.ps1 +++ b/internal/app/windows_update.ps1 @@ -5,6 +5,8 @@ $Target = $env:GONAVI_UPDATE_TARGET $CurrentTarget = $env:GONAVI_UPDATE_CURRENT_TARGET $StagedDir = $env:GONAVI_UPDATE_STAGED_DIR $LogPath = $env:GONAVI_UPDATE_LOG_PATH +$MaintenanceEventName = $env:GONAVI_UPDATE_MAINTENANCE_EVENT_NAME +$HandoffEventName = $env:GONAVI_UPDATE_HANDOFF_EVENT_NAME $HostProcessId = 0 $TargetOld = $null $ReplacementPrepared = $false @@ -14,6 +16,9 @@ $LaunchSucceeded = $false $HostExited = $false $SourceMatchesTarget = $false $RollbackSucceeded = $true +$MaintenanceEvent = $null +$HandoffEvent = $null +$MaintenanceLockReleased = $false function Write-UpdateLog { param([string]$Message) @@ -29,6 +34,25 @@ function Write-UpdateLog { } } +function Release-UpdateMaintenanceLock { + if ($MaintenanceLockReleased) { + return $true + } + try { + if ($null -eq $MaintenanceEvent) { + $script:MaintenanceLockReleased = $true + return $true + } + $script:MaintenanceEvent.Dispose() + $script:MaintenanceEvent = $null + $script:MaintenanceLockReleased = $true + return $true + } catch { + Write-UpdateLog ("maintenance lock release failed: " + $_.Exception.Message) + return $false + } +} + function Test-SamePath { param( [string]$Left, @@ -128,7 +152,7 @@ function Select-PortableExecutable { } try { - foreach ($requiredPath in @($Source, $Target, $CurrentTarget, $StagedDir, $LogPath)) { + foreach ($requiredPath in @($Source, $Target, $CurrentTarget, $StagedDir, $LogPath, $MaintenanceEventName, $HandoffEventName)) { if ([string]::IsNullOrWhiteSpace($requiredPath)) { throw 'missing required updater path' } @@ -146,6 +170,11 @@ try { throw 'source file not found' } + $MaintenanceEvent = [Threading.EventWaitHandle]::OpenExisting($MaintenanceEventName) + $HandoffEvent = [Threading.EventWaitHandle]::OpenExisting($HandoffEventName) + [void]$HandoffEvent.Set() + $HandoffEvent.Dispose() + $HandoffEvent = $null Write-UpdateLog 'updater started' $waitedSeconds = 0 while (Get-Process -Id $HostProcessId -ErrorAction SilentlyContinue) { @@ -222,6 +251,9 @@ try { throw 'replace failed after retries; package kept for manual install' } + if (-not (Release-UpdateMaintenanceLock)) { + throw 'update maintenance lock could not be released before relaunch' + } Write-UpdateLog ("launching target: " + $Target) $NewProcess = Start-Process -FilePath $Target -WorkingDirectory $TargetDir -PassThru -ErrorAction Stop Start-Sleep -Milliseconds 1500 @@ -261,7 +293,8 @@ try { if ($ReplacementPrepared -and -not $LaunchSucceeded -and -not $SourceMatchesTarget) { $RollbackSucceeded = Restore-PreviousTarget } - if ($HostExited -and -not $LaunchSucceeded -and -not $SourceMatchesTarget -and $RollbackSucceeded) { + [void](Release-UpdateMaintenanceLock) + if ($HostExited -and -not $LaunchSucceeded -and -not $SourceMatchesTarget -and $RollbackSucceeded -and $MaintenanceLockReleased) { try { if (Test-Path -LiteralPath $CurrentTarget -PathType Leaf) { $CurrentTargetDir = [IO.Path]::GetDirectoryName($CurrentTarget) diff --git a/internal/app/windows_update_instances_test.go b/internal/app/windows_update_instances_test.go new file mode 100644 index 00000000..2f4adb05 --- /dev/null +++ b/internal/app/windows_update_instances_test.go @@ -0,0 +1,96 @@ +package app + +import ( + "errors" + "reflect" + "testing" +) + +func TestWindowsUpdateRequiresExplicitCloseConfirmation(t *testing.T) { + tests := []struct { + goos string + confirmed bool + want bool + }{ + {goos: "windows", confirmed: false, want: true}, + {goos: " WINDOWS ", confirmed: true, want: false}, + {goos: "darwin", confirmed: false, want: false}, + {goos: "linux", confirmed: false, want: false}, + } + for _, test := range tests { + if got := windowsUpdateCloseConfirmationRequired(test.goos, test.confirmed); got != test.want { + t.Fatalf("windowsUpdateCloseConfirmationRequired(%q, %v) = %v, want %v", test.goos, test.confirmed, got, test.want) + } + } +} + +func TestCloseOtherWindowsUpdateInstancesForInstallUsesDiscoveredProcesses(t *testing.T) { + originalFind := updateFindOtherWindowsInstances + originalClose := updateCloseWindowsInstances + t.Cleanup(func() { + updateFindOtherWindowsInstances = originalFind + updateCloseWindowsInstances = originalClose + }) + + wantTargets := []string{`C:\\Program Files\\GoNavi\\GoNavi.exe`, `C:\\Program Files\\GoNavi\\GoNavi-new.exe`} + wantProcesses := []windowsUpdateProcess{ + {PID: 101, Executable: wantTargets[0]}, + {PID: 202, Executable: wantTargets[1]}, + } + findCalls := 0 + updateFindOtherWindowsInstances = func(targets []string, currentPID int) ([]windowsUpdateProcess, error) { + findCalls++ + if currentPID != 99 { + t.Fatalf("current PID = %d, want 99", currentPID) + } + if !reflect.DeepEqual(targets, wantTargets) { + t.Fatalf("target paths = %#v, want %#v", targets, wantTargets) + } + if findCalls == 1 { + return wantProcesses, nil + } + return nil, nil + } + var closed []windowsUpdateProcess + updateCloseWindowsInstances = func(processes []windowsUpdateProcess) error { + closed = append([]windowsUpdateProcess(nil), processes...) + return nil + } + + pids, err := closeOtherWindowsUpdateInstancesForInstall(wantTargets, 99) + if err != nil { + t.Fatalf("closeOtherWindowsUpdateInstancesForInstall returned error: %v", err) + } + if !reflect.DeepEqual(closed, wantProcesses) { + t.Fatalf("closed processes = %#v, want %#v", closed, wantProcesses) + } + if !reflect.DeepEqual(pids, []uint32{101, 202}) { + t.Fatalf("closed PIDs = %#v, want [101 202]", pids) + } + if findCalls != 2 { + t.Fatalf("find calls = %d, want initial discovery and post-close verification", findCalls) + } +} + +func TestCloseOtherWindowsUpdateInstancesForInstallPropagatesCloseFailure(t *testing.T) { + originalFind := updateFindOtherWindowsInstances + originalClose := updateCloseWindowsInstances + t.Cleanup(func() { + updateFindOtherWindowsInstances = originalFind + updateCloseWindowsInstances = originalClose + }) + + wantErr := errors.New("access denied") + updateFindOtherWindowsInstances = func([]string, int) ([]windowsUpdateProcess, error) { + return []windowsUpdateProcess{{PID: 303, Executable: `C:\\GoNavi.exe`}}, nil + } + updateCloseWindowsInstances = func([]windowsUpdateProcess) error { return wantErr } + + pids, err := closeOtherWindowsUpdateInstancesForInstall([]string{`C:\\GoNavi.exe`}, 99) + if !errors.Is(err, wantErr) { + t.Fatalf("close error = %v, want %v", err, wantErr) + } + if !reflect.DeepEqual(pids, []uint32{303}) { + t.Fatalf("failed close PIDs = %#v, want [303]", pids) + } +} diff --git a/internal/app/windows_update_integration_windows_test.go b/internal/app/windows_update_integration_windows_test.go index b709892f..837e95c7 100644 --- a/internal/app/windows_update_integration_windows_test.go +++ b/internal/app/windows_update_integration_windows_test.go @@ -45,14 +45,17 @@ func TestWindowsPowerShellUpdaterHandlesUnicodeAndShellMetacharacters(t *testing if err := os.WriteFile(scriptPath, []byte(buildWindowsPowerShellScript()), 0o644); err != nil { t.Fatalf("WriteFile updater: %v", err) } + maintenanceName, handoffName := prepareWindowsUpdateIntegrationEvents(t) context := windowsUpdateLaunchContext{ - SourcePath: sourcePath, - TargetPath: targetPath, - CurrentTargetPath: targetPath, - StagedDir: stagedDir, - LogPath: logPath, - PID: 2147483647, + SourcePath: sourcePath, + TargetPath: targetPath, + CurrentTargetPath: targetPath, + StagedDir: stagedDir, + LogPath: logPath, + MaintenanceEventName: maintenanceName, + HandoffEventName: handoffName, + PID: 2147483647, } cmd := buildWindowsLaunchCommand(scriptPath, context) if output, err := cmd.CombinedOutput(); err != nil { @@ -119,14 +122,17 @@ func TestWindowsPowerShellUpdaterRenamesVersionedPortableExecutable(t *testing.T if err := os.WriteFile(scriptPath, []byte(buildWindowsPowerShellScript()), 0o644); err != nil { t.Fatalf("WriteFile updater: %v", err) } + maintenanceName, handoffName := prepareWindowsUpdateIntegrationEvents(t) cmd := buildWindowsLaunchCommand(scriptPath, windowsUpdateLaunchContext{ - SourcePath: sourcePath, - TargetPath: targetPath, - CurrentTargetPath: currentTargetPath, - StagedDir: stagedDir, - LogPath: logPath, - PID: 2147483647, + SourcePath: sourcePath, + TargetPath: targetPath, + CurrentTargetPath: currentTargetPath, + StagedDir: stagedDir, + LogPath: logPath, + MaintenanceEventName: maintenanceName, + HandoffEventName: handoffName, + PID: 2147483647, }) if output, err := cmd.CombinedOutput(); err != nil { logData, _ := os.ReadFile(logPath) @@ -183,14 +189,17 @@ func TestWindowsPowerShellUpdaterSelectsExactTargetFilenameRecursivelyFromZip(t if err := os.WriteFile(scriptPath, []byte(buildWindowsPowerShellScript()), 0o644); err != nil { t.Fatalf("WriteFile updater: %v", err) } + maintenanceName, handoffName := prepareWindowsUpdateIntegrationEvents(t) cmd := buildWindowsLaunchCommand(scriptPath, windowsUpdateLaunchContext{ - SourcePath: sourcePath, - TargetPath: targetPath, - CurrentTargetPath: targetPath, - StagedDir: stagedDir, - LogPath: logPath, - PID: 2147483647, + SourcePath: sourcePath, + TargetPath: targetPath, + CurrentTargetPath: targetPath, + StagedDir: stagedDir, + LogPath: logPath, + MaintenanceEventName: maintenanceName, + HandoffEventName: handoffName, + PID: 2147483647, }) if output, err := cmd.CombinedOutput(); err != nil { logData, _ := os.ReadFile(logPath) @@ -233,14 +242,17 @@ func TestWindowsPowerShellUpdaterRejectsAmbiguousZipAndRetainsPackage(t *testing if err := os.WriteFile(scriptPath, []byte(buildWindowsPowerShellScript()), 0o644); err != nil { t.Fatalf("WriteFile updater: %v", err) } + maintenanceName, handoffName := prepareWindowsUpdateIntegrationEvents(t) cmd := buildWindowsLaunchCommand(scriptPath, windowsUpdateLaunchContext{ - SourcePath: sourcePath, - TargetPath: targetPath, - CurrentTargetPath: targetPath, - StagedDir: stagedDir, - LogPath: logPath, - PID: 2147483647, + SourcePath: sourcePath, + TargetPath: targetPath, + CurrentTargetPath: targetPath, + StagedDir: stagedDir, + LogPath: logPath, + MaintenanceEventName: maintenanceName, + HandoffEventName: handoffName, + PID: 2147483647, }) if output, err := cmd.CombinedOutput(); err == nil { t.Fatalf("ambiguous ZIP updater unexpectedly succeeded\n%s", output) @@ -271,6 +283,23 @@ type windowsUpdateZipEntry struct { Data []byte } +func prepareWindowsUpdateIntegrationEvents(t *testing.T) (string, string) { + t.Helper() + name := fmt.Sprintf(`Global\GoNavi-Update-Integration-%d-%d`, os.Getpid(), time.Now().UnixNano()) + lease, err := acquireWindowsUpdateMaintenanceObject(name) + if err != nil { + t.Fatalf("acquire integration maintenance object: %v", err) + } + handoff, err := prepareWindowsUpdateHandoff() + if err != nil { + lease.Release() + t.Fatalf("prepare integration handoff: %v", err) + } + t.Cleanup(lease.Release) + t.Cleanup(handoff.Close) + return lease.Name, handoff.Name +} + func writeWindowsUpdateTestZip(t *testing.T, path string, entries []windowsUpdateZipEntry) { t.Helper() diff --git a/internal/app/windows_update_maintenance.go b/internal/app/windows_update_maintenance.go new file mode 100644 index 00000000..1e485bea --- /dev/null +++ b/internal/app/windows_update_maintenance.go @@ -0,0 +1,56 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "path/filepath" + "strings" +) + +type windowsUpdateMaintenanceLease struct { + Name string + Release func() +} + +type windowsUpdateHandoff struct { + Name string + Wait func() error + Close func() +} + +func WindowsUpdateMaintenanceActive(goos string, executablePath string) (bool, error) { + if !strings.EqualFold(strings.TrimSpace(goos), "windows") { + return false, nil + } + name, err := resolveWindowsUpdateMaintenanceName(executablePath) + if err != nil { + return false, err + } + return windowsUpdateMaintenanceObjectActive(name) +} + +func acquireWindowsUpdateMaintenance(executablePath string) (windowsUpdateMaintenanceLease, error) { + name, err := resolveWindowsUpdateMaintenanceName(executablePath) + if err != nil { + return windowsUpdateMaintenanceLease{}, err + } + return acquireWindowsUpdateMaintenanceObject(name) +} + +func resolveWindowsUpdateMaintenanceName(executablePath string) (string, error) { + executablePath = strings.TrimSpace(executablePath) + if executablePath == "" { + return "", errors.New("update maintenance executable path is empty") + } + absolute, err := filepath.Abs(executablePath) + if err != nil { + return "", err + } + if evaluated, evalErr := filepath.EvalSymlinks(absolute); evalErr == nil { + absolute = evaluated + } + installDir := strings.ToLower(filepath.Clean(filepath.Dir(absolute))) + digest := sha256.Sum256([]byte(installDir)) + return `Global\GoNavi-Update-` + hex.EncodeToString(digest[:16]), nil +} diff --git a/internal/app/windows_update_maintenance_stub.go b/internal/app/windows_update_maintenance_stub.go new file mode 100644 index 00000000..b1044b18 --- /dev/null +++ b/internal/app/windows_update_maintenance_stub.go @@ -0,0 +1,17 @@ +//go:build !windows + +package app + +import "errors" + +func windowsUpdateMaintenanceObjectActive(string) (bool, error) { + return false, nil +} + +func acquireWindowsUpdateMaintenanceObject(string) (windowsUpdateMaintenanceLease, error) { + return windowsUpdateMaintenanceLease{}, errors.New("Windows update maintenance is unavailable on this platform") +} + +func prepareWindowsUpdateHandoff() (windowsUpdateHandoff, error) { + return windowsUpdateHandoff{}, errors.New("Windows update handoff is unavailable on this platform") +} diff --git a/internal/app/windows_update_maintenance_test.go b/internal/app/windows_update_maintenance_test.go new file mode 100644 index 00000000..a200f2d5 --- /dev/null +++ b/internal/app/windows_update_maintenance_test.go @@ -0,0 +1,31 @@ +package app + +import ( + "path/filepath" + "testing" +) + +func TestResolveWindowsUpdateMaintenanceNameUsesInstallDirectoryIdentity(t *testing.T) { + installDir := t.TempDir() + first, err := resolveWindowsUpdateMaintenanceName(filepath.Join(installDir, "GoNavi.exe")) + if err != nil { + t.Fatalf("resolve first maintenance name: %v", err) + } + second, err := resolveWindowsUpdateMaintenanceName(filepath.Join(installDir, "GoNavi-new.exe")) + if err != nil { + t.Fatalf("resolve second maintenance name: %v", err) + } + if first != second { + t.Fatalf("same install directory produced different names: %q != %q", first, second) + } + other, err := resolveWindowsUpdateMaintenanceName(filepath.Join(t.TempDir(), "GoNavi.exe")) + if err != nil { + t.Fatalf("resolve other maintenance name: %v", err) + } + if first == other { + t.Fatalf("different install directories produced the same name: %q", first) + } + if len(first) <= len(`Global\GoNavi-Update-`) || first[:len(`Global\GoNavi-Update-`)] != `Global\GoNavi-Update-` { + t.Fatalf("maintenance name = %q, want Global namespace", first) + } +} diff --git a/internal/app/windows_update_maintenance_windows.go b/internal/app/windows_update_maintenance_windows.go new file mode 100644 index 00000000..c8ee3889 --- /dev/null +++ b/internal/app/windows_update_maintenance_windows.go @@ -0,0 +1,98 @@ +//go:build windows + +package app + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "sync" + "time" + + "golang.org/x/sys/windows" +) + +const windowsUpdateHandoffTimeout = 15 * time.Second + +func windowsUpdateMaintenanceObjectActive(name string) (bool, error) { + namePtr, err := windows.UTF16PtrFromString(name) + if err != nil { + return false, fmt.Errorf("build update maintenance object name: %w", err) + } + handle, err := windows.OpenEvent(windows.SYNCHRONIZE, false, namePtr) + if err == nil { + windows.CloseHandle(handle) + return true, nil + } + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + return false, nil + } + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return true, nil + } + return false, fmt.Errorf("open update maintenance object: %w", err) +} + +func acquireWindowsUpdateMaintenanceObject(name string) (windowsUpdateMaintenanceLease, error) { + namePtr, err := windows.UTF16PtrFromString(name) + if err != nil { + return windowsUpdateMaintenanceLease{}, fmt.Errorf("build update maintenance object name: %w", err) + } + handle, createErr := windows.CreateEvent(nil, 1, 0, namePtr) + if errors.Is(createErr, windows.ERROR_ALREADY_EXISTS) { + windows.CloseHandle(handle) + return windowsUpdateMaintenanceLease{}, errors.New("another update is already in progress for this GoNavi installation") + } + if createErr != nil { + if errors.Is(createErr, windows.ERROR_ACCESS_DENIED) { + return windowsUpdateMaintenanceLease{}, errors.New("another Windows session is updating this GoNavi installation") + } + return windowsUpdateMaintenanceLease{}, fmt.Errorf("create update maintenance object: %w", createErr) + } + + var releaseOnce sync.Once + release := func() { + releaseOnce.Do(func() { + windows.CloseHandle(handle) + }) + } + return windowsUpdateMaintenanceLease{Name: name, Release: release}, nil +} + +func prepareWindowsUpdateHandoff() (windowsUpdateHandoff, error) { + random := make([]byte, 16) + if _, err := rand.Read(random); err != nil { + return windowsUpdateHandoff{}, fmt.Errorf("create update handoff name: %w", err) + } + name := `Local\GoNavi-Update-Handoff-` + hex.EncodeToString(random) + namePtr, err := windows.UTF16PtrFromString(name) + if err != nil { + return windowsUpdateHandoff{}, fmt.Errorf("build update handoff name: %w", err) + } + handle, err := windows.CreateEvent(nil, 1, 0, namePtr) + if err != nil { + if handle != 0 { + windows.CloseHandle(handle) + } + return windowsUpdateHandoff{}, fmt.Errorf("create update handoff event: %w", err) + } + + var closeOnce sync.Once + closeHandoff := func() { + closeOnce.Do(func() { + windows.CloseHandle(handle) + }) + } + wait := func() error { + event, err := windows.WaitForSingleObject(handle, uint32(windowsUpdateHandoffTimeout.Milliseconds())) + if err != nil { + return fmt.Errorf("wait for Windows updater handoff: %w", err) + } + if event != windows.WAIT_OBJECT_0 { + return errors.New("Windows updater did not accept maintenance ownership in time") + } + return nil + } + return windowsUpdateHandoff{Name: name, Wait: wait, Close: closeHandoff}, nil +} diff --git a/internal/app/windows_update_maintenance_windows_test.go b/internal/app/windows_update_maintenance_windows_test.go new file mode 100644 index 00000000..00953f45 --- /dev/null +++ b/internal/app/windows_update_maintenance_windows_test.go @@ -0,0 +1,55 @@ +//go:build windows + +package app + +import ( + "fmt" + "os" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +func TestAcquireWindowsUpdateMaintenanceBlocksUntilRelease(t *testing.T) { + name := fmt.Sprintf(`Global\GoNavi-Update-Test-%d-%d`, os.Getpid(), time.Now().UnixNano()) + lease, err := acquireWindowsUpdateMaintenanceObject(name) + if err != nil { + t.Fatalf("acquire maintenance object: %v", err) + } + t.Cleanup(lease.Release) + if active, err := windowsUpdateMaintenanceObjectActive(name); err != nil || !active { + t.Fatalf("maintenance active = %v error = %v, want active", active, err) + } + if _, err := acquireWindowsUpdateMaintenanceObject(name); err == nil { + t.Fatal("second maintenance acquisition unexpectedly succeeded") + } + lease.Release() + if active, err := windowsUpdateMaintenanceObjectActive(name); err != nil || active { + t.Fatalf("maintenance active after release = %v error = %v, want inactive", active, err) + } +} + +func TestPrepareWindowsUpdateHandoffWaitsForSignal(t *testing.T) { + handoff, err := prepareWindowsUpdateHandoff() + if err != nil { + t.Fatalf("prepare update handoff: %v", err) + } + t.Cleanup(handoff.Close) + namePtr, err := windows.UTF16PtrFromString(handoff.Name) + if err != nil { + t.Fatalf("build handoff name: %v", err) + } + handle, err := windows.OpenEvent(windows.EVENT_MODIFY_STATE, false, namePtr) + if err != nil { + t.Fatalf("open update handoff event: %v", err) + } + if err := windows.SetEvent(handle); err != nil { + windows.CloseHandle(handle) + t.Fatalf("signal update handoff event: %v", err) + } + windows.CloseHandle(handle) + if err := handoff.Wait(); err != nil { + t.Fatalf("wait for update handoff: %v", err) + } +} diff --git a/internal/app/windows_update_process.go b/internal/app/windows_update_process.go index 0a4e41ec..58b0316d 100644 --- a/internal/app/windows_update_process.go +++ b/internal/app/windows_update_process.go @@ -1,5 +1,10 @@ package app +import ( + "fmt" + "strings" +) + func otherWindowsUpdateProcessIDs(processes []windowsUpdateProcess) []uint32 { result := make([]uint32, 0, len(processes)) for _, process := range processes { @@ -7,3 +12,29 @@ func otherWindowsUpdateProcessIDs(processes []windowsUpdateProcess) []uint32 { } return result } + +func windowsUpdateCloseConfirmationRequired(goos string, confirmed bool) bool { + return strings.EqualFold(strings.TrimSpace(goos), "windows") && !confirmed +} + +func closeOtherWindowsUpdateInstancesForInstall(targetPaths []string, currentPID int) ([]uint32, error) { + instances, err := updateFindOtherWindowsInstances(targetPaths, currentPID) + if err != nil { + return nil, err + } + pids := otherWindowsUpdateProcessIDs(instances) + if len(instances) == 0 { + return pids, nil + } + if err := updateCloseWindowsInstances(instances); err != nil { + return pids, err + } + remaining, err := updateFindOtherWindowsInstances(targetPaths, currentPID) + if err != nil { + return pids, err + } + if len(remaining) > 0 { + return pids, fmt.Errorf("GoNavi processes still running after close: %v", otherWindowsUpdateProcessIDs(remaining)) + } + return pids, nil +} diff --git a/internal/app/windows_update_process_stub.go b/internal/app/windows_update_process_stub.go index ec240c77..05c4e18c 100644 --- a/internal/app/windows_update_process_stub.go +++ b/internal/app/windows_update_process_stub.go @@ -5,3 +5,7 @@ package app func findOtherWindowsUpdateInstances(_ []string, _ int) ([]windowsUpdateProcess, error) { return nil, nil } + +func closeWindowsUpdateInstances(_ []windowsUpdateProcess) error { + return nil +} diff --git a/internal/app/windows_update_process_windows.go b/internal/app/windows_update_process_windows.go index 08158d96..58c2339c 100644 --- a/internal/app/windows_update_process_windows.go +++ b/internal/app/windows_update_process_windows.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "syscall" + "time" "unsafe" "golang.org/x/sys/windows" @@ -16,6 +17,14 @@ import ( const windowsCreateNoWindow = 0x08000000 +const ( + windowsCloseMessage = 0x0010 + windowsGracefulProcessCloseWait = 1500 * time.Millisecond + windowsForcedProcessCloseTimeout = 10 * time.Second +) + +var windowsPostMessage = windows.NewLazySystemDLL("user32.dll").NewProc("PostMessageW") + func configureWindowsUpdateCommand(cmd *exec.Cmd) { if cmd == nil { return @@ -28,8 +37,12 @@ func configureWindowsUpdateCommand(cmd *exec.Cmd) { func findOtherWindowsUpdateInstances(targetPaths []string, currentPID int) ([]windowsUpdateProcess, error) { targets := make(map[string]struct{}, len(targetPaths)*2) + targetNames := make(map[string]struct{}, len(targetPaths)) for _, targetPath := range targetPaths { addWindowsUpdateComparablePath(targets, targetPath) + if name := strings.ToLower(filepath.Base(strings.TrimSpace(targetPath))); name != "" && name != "." { + targetNames[name] = struct{}{} + } } if len(targets) == 0 { return nil, nil @@ -53,8 +66,14 @@ func findOtherWindowsUpdateInstances(targetPaths []string, currentPID int) ([]wi for { pid := entry.ProcessID if pid != 0 && int(pid) != currentPID { - if executable, ok := queryWindowsProcessExecutable(pid); ok && windowsUpdatePathMatches(targets, executable) { + executable, queryErr := queryWindowsProcessExecutable(pid) + if queryErr == nil && windowsUpdatePathMatches(targets, executable) { result = append(result, windowsUpdateProcess{PID: pid, Executable: executable}) + } else if queryErr != nil && !errors.Is(queryErr, windows.ERROR_INVALID_PARAMETER) { + entryName := strings.ToLower(windows.UTF16ToString(entry.ExeFile[:])) + if _, mayBeTarget := targetNames[entryName]; mayBeTarget { + return nil, fmt.Errorf("inspect possible GoNavi process %d (%s): %w", pid, entryName, queryErr) + } } } @@ -68,19 +87,154 @@ func findOtherWindowsUpdateInstances(targetPaths []string, currentPID int) ([]wi return result, nil } -func queryWindowsProcessExecutable(pid uint32) (string, bool) { +func closeWindowsUpdateInstances(processes []windowsUpdateProcess) error { + unique := make(map[uint32]windowsUpdateProcess, len(processes)) + for _, process := range processes { + if process.PID != 0 { + unique[process.PID] = process + } + } + if len(unique) == 0 { + return nil + } + + opened, err := openWindowsUpdateProcesses(unique) + if err != nil { + return err + } + defer func() { + for _, process := range opened { + windows.CloseHandle(process.handle) + } + }() + if len(opened) == 0 { + return nil + } + + openedByPID := make(map[uint32]windowsUpdateProcess, len(opened)) + for _, process := range opened { + openedByPID[process.process.PID] = process.process + } + requestWindowsProcessesClose(openedByPID) + var closeErrors []error + for _, process := range opened { + if err := closeWindowsUpdateProcess(process); err != nil { + closeErrors = append(closeErrors, err) + } + } + return errors.Join(closeErrors...) +} + +func requestWindowsProcessesClose(processes map[uint32]windowsUpdateProcess) { + callback := syscall.NewCallback(func(hwnd uintptr, _ uintptr) uintptr { + var pid uint32 + if _, err := windows.GetWindowThreadProcessId(windows.HWND(hwnd), &pid); err == nil { + if _, ok := processes[pid]; ok { + windowsPostMessage.Call(hwnd, windowsCloseMessage, 0, 0) + } + } + return 1 + }) + _ = windows.EnumWindows(callback, nil) +} + +type openedWindowsUpdateProcess struct { + process windowsUpdateProcess + handle windows.Handle +} + +func openWindowsUpdateProcesses(processes map[uint32]windowsUpdateProcess) ([]openedWindowsUpdateProcess, error) { + opened := make([]openedWindowsUpdateProcess, 0, len(processes)) + for _, process := range processes { + handle, err := windows.OpenProcess( + windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, + false, + process.PID, + ) + if err != nil { + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + continue + } + for _, item := range opened { + windows.CloseHandle(item.handle) + } + return nil, fmt.Errorf("open GoNavi process %d (%s): %w", process.PID, process.Executable, err) + } + + actualExecutable, queryErr := queryWindowsProcessExecutableFromHandle(handle) + if queryErr != nil { + windows.CloseHandle(handle) + for _, item := range opened { + windows.CloseHandle(item.handle) + } + return nil, fmt.Errorf("verify GoNavi process %d (%s): %w", process.PID, process.Executable, queryErr) + } + expectedPaths := make(map[string]struct{}, 2) + addWindowsUpdateComparablePath(expectedPaths, process.Executable) + if !windowsUpdatePathMatches(expectedPaths, actualExecutable) { + windows.CloseHandle(handle) + for _, item := range opened { + windows.CloseHandle(item.handle) + } + return nil, fmt.Errorf( + "GoNavi process %d executable changed from %s to %s", + process.PID, + process.Executable, + actualExecutable, + ) + } + opened = append(opened, openedWindowsUpdateProcess{process: process, handle: handle}) + } + return opened, nil +} + +func closeWindowsUpdateProcess(opened openedWindowsUpdateProcess) error { + process := opened.process + handle := opened.handle + + event, err := windows.WaitForSingleObject(handle, uint32(windowsGracefulProcessCloseWait.Milliseconds())) + if err != nil { + return fmt.Errorf("wait for GoNavi process %d to close: %w", process.PID, err) + } + if event == windows.WAIT_OBJECT_0 { + return nil + } + if event != uint32(windows.WAIT_TIMEOUT) { + return fmt.Errorf("wait for GoNavi process %d returned status %#x", process.PID, event) + } + + if err := windows.TerminateProcess(handle, 0); err != nil { + return fmt.Errorf("terminate GoNavi process %d (%s): %w", process.PID, process.Executable, err) + } + event, err = windows.WaitForSingleObject(handle, uint32(windowsForcedProcessCloseTimeout.Milliseconds())) + if err != nil { + return fmt.Errorf("wait for terminated GoNavi process %d: %w", process.PID, err) + } + if event != windows.WAIT_OBJECT_0 { + return fmt.Errorf("GoNavi process %d did not exit after termination", process.PID) + } + return nil +} + +func queryWindowsProcessExecutable(pid uint32) (string, error) { process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) if err != nil { - return "", false + return "", err } defer windows.CloseHandle(process) + return queryWindowsProcessExecutableFromHandle(process) +} +func queryWindowsProcessExecutableFromHandle(process windows.Handle) (string, error) { buffer := make([]uint16, windows.MAX_LONG_PATH) size := uint32(len(buffer)) - if err := windows.QueryFullProcessImageName(process, 0, &buffer[0], &size); err != nil || size == 0 { - return "", false + if err := windows.QueryFullProcessImageName(process, 0, &buffer[0], &size); err != nil { + return "", err } - return windows.UTF16ToString(buffer[:size]), true + if size == 0 { + return "", errors.New("process executable path is empty") + } + return windows.UTF16ToString(buffer[:size]), nil } func windowsUpdatePathMatches(targets map[string]struct{}, executable string) bool { diff --git a/internal/app/windows_update_script.go b/internal/app/windows_update_script.go index 628083e0..7d6a1fe5 100644 --- a/internal/app/windows_update_script.go +++ b/internal/app/windows_update_script.go @@ -9,12 +9,14 @@ import ( var windowsUpdatePowerShellScript string type windowsUpdateLaunchContext struct { - SourcePath string - TargetPath string - CurrentTargetPath string - StagedDir string - LogPath string - PID int + SourcePath string + TargetPath string + CurrentTargetPath string + StagedDir string + LogPath string + MaintenanceEventName string + HandoffEventName string + PID int } func buildWindowsPowerShellScript() string { diff --git a/main.go b/main.go index ee342994..e78a262c 100644 --- a/main.go +++ b/main.go @@ -81,6 +81,17 @@ func main() { debug.SetGCPercent(50) executablePath, executableErr := os.Executable() + if executableErr == nil { + maintenanceActive, err := app.WindowsUpdateMaintenanceActive(runtime.GOOS, executablePath) + if err != nil { + logger.Errorf("检查 Windows 更新维护状态失败:%v", err) + return + } + if maintenanceActive { + logger.Warnf("当前 GoNavi 安装正在更新,已阻止新进程启动:%s", executablePath) + return + } + } if runSpecialMode(os.Args[1:]) { return } diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 30f20939..a2058d9f 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -3030,6 +3030,9 @@ "app.update.action.install_update": "Update installieren", "app.update.action.open_install_directory": "Installationsverzeichnis öffnen", "app.update.backend.error.channel_invalid": "Ungültiger Update-Kanal: {{channel}}", + "app.update.backend.error.close_instances_confirmation_required": "Bestätigen Sie vor der Installation, dass alle Instanzen dieser GoNavi-Installation geschlossen werden dürfen", + "app.update.backend.error.close_instances_failed": "Die Instanzen dieser GoNavi-Installation konnten nicht geschlossen werden: {{detail}}", + "app.update.backend.error.maintenance_lock_failed": "Der Update-Wartungsmodus konnte nicht gestartet werden: {{detail}}", "app.update.backend.error.check_failed": "Updateprüfung fehlgeschlagen: {{detail}}", "app.update.backend.error.check_http_forbidden": "Update-Prüfung fehlgeschlagen: GitHub meldete 403 Forbidden. Prüfen Sie den Zugriff auf api.github.com über Netzwerk/Proxy oder setzen Sie GONAVI_GITHUB_TOKEN. {{detail}}", "app.update.backend.error.check_http_rate_limited": "Update-Prüfung fehlgeschlagen: Der Update-Dienst ist ausgelastet oder Ihr Netzausgang ist begrenzt. Bitte später erneut versuchen. Endnutzer müssen kein Token konfigurieren. {{detail}}", @@ -8283,6 +8286,9 @@ "app.about.action.download_portable_update": "Portable-Update herunterladen", "app.about.action.install_and_restart": "Installieren und neu starten", "app.about.action.launch_installer": "Installer starten", + "app.about.update_install_confirm.close_instances_title": "Alle Instanzen dieser GoNavi-Installation schließen und das Update installieren?", + "app.about.update_install_confirm.close_instances_content": "Zunächst werden nicht gespeicherte SQL-Änderungen in diesem Fenster behandelt. Anschließend schließt Windows alle weiteren Prozesse dieser Installation und startet das Installationsprogramm; Portable-Kopien in anderen Ordnern bleiben geöffnet. Nicht gespeicherte Inhalte in geschlossenen Instanzen können verloren gehen.", + "app.about.update_install_confirm.close_instances_ok": "Alle schließen und installieren", "app.about.download_progress.ready_to_restart": "Download abgeschlossen (100%). Klicken Sie auf „Zum Aktualisieren neu starten“.", "app.about.download_progress.ready_to_install": "MSI-Installer heruntergeladen (100%) und installationsbereit.", "app.about.download_progress.downloading": "Update wird heruntergeladen…", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 52d3ffea..ee8fb3bf 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -3030,6 +3030,9 @@ "app.update.action.install_update": "Install Update", "app.update.action.open_install_directory": "Open Install Directory", "app.update.backend.error.channel_invalid": "Invalid update channel: {{channel}}", + "app.update.backend.error.close_instances_confirmation_required": "Confirm closing all instances from the current GoNavi installation before installing the update", + "app.update.backend.error.close_instances_failed": "Failed to close the current GoNavi installation: {{detail}}", + "app.update.backend.error.maintenance_lock_failed": "Failed to enter update maintenance mode: {{detail}}", "app.update.backend.error.check_failed": "Check for updates failed: {{detail}}", "app.update.backend.error.check_http_forbidden": "Update check failed: GitHub returned 403 Forbidden. Ensure api.github.com is reachable via your network/proxy, or set GONAVI_GITHUB_TOKEN. {{detail}}", "app.update.backend.error.check_http_rate_limited": "Update check failed: the update service is busy or your network egress is rate-limited. Please try again later. End users do not need to configure any token. {{detail}}", @@ -8283,6 +8286,9 @@ "app.about.action.download_portable_update": "Download Portable update", "app.about.action.install_and_restart": "Install and restart", "app.about.action.launch_installer": "Launch installer", + "app.about.update_install_confirm.close_instances_title": "Close all instances from this GoNavi installation and install the update?", + "app.about.update_install_confirm.close_instances_content": "GoNavi will first handle unsaved SQL in this window. Windows will then close every other process from this installation and start the installer; Portable copies in other folders are not affected. Unsaved work in closed instances may be lost.", + "app.about.update_install_confirm.close_instances_ok": "Close all and install", "app.about.download_progress.ready_to_restart": "Download complete (100%). Click “Restart to update” to finish installing.", "app.about.download_progress.ready_to_install": "MSI installer downloaded (100%) and ready to install.", "app.about.download_progress.downloading": "Downloading update…", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 757bdcb2..17541593 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -3030,6 +3030,9 @@ "app.update.action.install_update": "更新をインストール", "app.update.action.open_install_directory": "インストールディレクトリを開く", "app.update.backend.error.channel_invalid": "無効な更新チャネルです: {{channel}}", + "app.update.backend.error.close_instances_confirmation_required": "更新をインストールする前に、現在の GoNavi インストールの全インスタンスを終了することを確認してください", + "app.update.backend.error.close_instances_failed": "現在の GoNavi インストールのインスタンスを終了できませんでした: {{detail}}", + "app.update.backend.error.maintenance_lock_failed": "更新メンテナンスモードを開始できませんでした: {{detail}}", "app.update.backend.error.check_failed": "更新確認に失敗しました: {{detail}}", "app.update.backend.error.check_http_forbidden": "更新確認に失敗しました: GitHub が 403 を返しました。api.github.com への通信/プロキシを確認するか、GONAVI_GITHUB_TOKEN を設定してください。{{detail}}", "app.update.backend.error.check_http_rate_limited": "更新確認に失敗しました: 更新サーバーが混雑しているか、ネットワーク出口が制限されています。しばらくしてから再試行してください。一般ユーザーは Token の設定は不要です。{{detail}}", @@ -8283,6 +8286,9 @@ "app.about.action.download_portable_update": "Portable 更新をダウンロード", "app.about.action.install_and_restart": "インストールして再起動", "app.about.action.launch_installer": "インストーラーを起動", + "app.about.update_install_confirm.close_instances_title": "現在の GoNavi インストールの全インスタンスを終了して更新しますか?", + "app.about.update_install_confirm.close_instances_content": "続行すると、このウィンドウの未保存 SQL を先に処理した後、Windows が現在のインストールに属する他の GoNavi プロセスを終了してインストールを開始します。別のフォルダーにある Portable 版は影響を受けません。終了するインスタンスの未保存内容は失われる可能性があります。", + "app.about.update_install_confirm.close_instances_ok": "すべて終了してインストール", "app.about.download_progress.ready_to_restart": "ダウンロード完了(100%)。「再起動して更新」をクリックしてください。", "app.about.download_progress.ready_to_install": "MSI インストーラーのダウンロードが完了しました(100%)。", "app.about.download_progress.downloading": "更新をダウンロード中…", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index ab641bf2..c78550a1 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -3030,6 +3030,9 @@ "app.update.action.install_update": "Установить обновление", "app.update.action.open_install_directory": "Открыть каталог установки", "app.update.backend.error.channel_invalid": "Недопустимый канал обновления: {{channel}}", + "app.update.backend.error.close_instances_confirmation_required": "Перед установкой обновления подтвердите закрытие всех экземпляров текущей установки GoNavi", + "app.update.backend.error.close_instances_failed": "Не удалось закрыть экземпляры текущей установки GoNavi: {{detail}}", + "app.update.backend.error.maintenance_lock_failed": "Не удалось перейти в режим обслуживания обновления: {{detail}}", "app.update.backend.error.check_failed": "Не удалось проверить обновления: {{detail}}", "app.update.backend.error.check_http_forbidden": "Не удалось проверить обновления: GitHub вернул 403. Проверьте доступ к api.github.com через сеть/прокси или задайте GONAVI_GITHUB_TOKEN. {{detail}}", "app.update.backend.error.check_http_rate_limited": "Не удалось проверить обновления: служба обновлений занята или сетевой выход ограничен. Повторите позже. Конечным пользователям не нужно настраивать токен. {{detail}}", @@ -8283,6 +8286,9 @@ "app.about.action.download_portable_update": "Скачать Portable-обновление", "app.about.action.install_and_restart": "Установить и перезапустить", "app.about.action.launch_installer": "Запустить установщик", + "app.about.update_install_confirm.close_instances_title": "Закрыть все экземпляры текущей установки GoNavi и установить обновление?", + "app.about.update_install_confirm.close_instances_content": "Сначала GoNavi обработает несохранённый SQL в этом окне. Затем Windows закроет остальные процессы текущей установки и запустит установщик; Portable-копии в других папках не будут затронуты. Несохранённые данные в закрываемых экземплярах могут быть потеряны.", + "app.about.update_install_confirm.close_instances_ok": "Закрыть все и установить", "app.about.download_progress.ready_to_restart": "Загрузка завершена (100%). Нажмите «Перезапустить для обновления».", "app.about.download_progress.ready_to_install": "MSI-установщик загружен (100%) и готов к установке.", "app.about.download_progress.downloading": "Загрузка обновления…", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index c0f41fa5..e8cb7850 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -3030,6 +3030,9 @@ "app.update.action.install_update": "安装更新", "app.update.action.open_install_directory": "打开安装目录", "app.update.backend.error.channel_invalid": "无效的更新通道:{{channel}}", + "app.update.backend.error.close_instances_confirmation_required": "安装更新前需要确认关闭当前安装的所有 GoNavi 实例", + "app.update.backend.error.close_instances_failed": "关闭当前安装的 GoNavi 实例失败:{{detail}}", + "app.update.backend.error.maintenance_lock_failed": "进入更新维护状态失败:{{detail}}", "app.update.backend.error.check_failed": "检查更新失败:{{detail}}", "app.update.backend.error.check_http_forbidden": "检查更新失败:GitHub 返回 403(访问被拒绝)。请确认网络/代理可访问 api.github.com,或配置 GONAVI_GITHUB_TOKEN。{{detail}}", "app.update.backend.error.check_http_rate_limited": "检查更新失败:更新服务暂时繁忙或网络出口被限流。请稍后再试;普通用户无需配置任何 Token。{{detail}}", @@ -8283,6 +8286,9 @@ "app.about.action.download_portable_update": "下载 Portable 更新", "app.about.action.install_and_restart": "安装并重启", "app.about.action.launch_installer": "启动安装程序", + "app.about.update_install_confirm.close_instances_title": "关闭当前安装的所有 GoNavi 实例并安装更新?", + "app.about.update_install_confirm.close_instances_content": "继续后,当前窗口会先处理未保存的 SQL,然后 Windows 将关闭当前安装中的其他 GoNavi 进程并开始安装;其他目录中的 Portable 副本不会受影响。被关闭实例中未保存的内容可能会丢失。", + "app.about.update_install_confirm.close_instances_ok": "关闭全部并安装", "app.about.download_progress.ready_to_restart": "下载完成(100%)。点击「重启应用更新」即可完成安装。", "app.about.download_progress.ready_to_install": "MSI 安装包下载完成(100%),可以开始安装。", "app.about.download_progress.downloading": "正在下载更新…", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index a9706a75..647fbcac 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -3030,6 +3030,9 @@ "app.update.action.install_update": "安裝更新", "app.update.action.open_install_directory": "開啟安裝目錄", "app.update.backend.error.channel_invalid": "無效的更新通道:{{channel}}", + "app.update.backend.error.close_instances_confirmation_required": "安裝更新前需要確認關閉目前安裝的所有 GoNavi 執行個體", + "app.update.backend.error.close_instances_failed": "關閉目前安裝的 GoNavi 執行個體失敗:{{detail}}", + "app.update.backend.error.maintenance_lock_failed": "進入更新維護狀態失敗:{{detail}}", "app.update.backend.error.check_failed": "檢查更新失敗:{{detail}}", "app.update.backend.error.check_http_forbidden": "檢查更新失敗:GitHub 回傳 403(存取被拒絕)。請確認網路/代理可存取 api.github.com,或設定 GONAVI_GITHUB_TOKEN。{{detail}}", "app.update.backend.error.check_http_rate_limited": "檢查更新失敗:更新服務暫時繁忙或網路出口被限流。請稍後再試;一般使用者無需設定任何 Token。{{detail}}", @@ -8283,6 +8286,9 @@ "app.about.action.download_portable_update": "下載 Portable 更新", "app.about.action.install_and_restart": "安裝並重新啟動", "app.about.action.launch_installer": "啟動安裝程式", + "app.about.update_install_confirm.close_instances_title": "關閉目前安裝的所有 GoNavi 執行個體並安裝更新?", + "app.about.update_install_confirm.close_instances_content": "繼續後,目前視窗會先處理未儲存的 SQL,然後 Windows 將關閉目前安裝中的其他 GoNavi 處理程序並開始安裝;其他資料夾中的 Portable 副本不受影響。被關閉執行個體中未儲存的內容可能會遺失。", + "app.about.update_install_confirm.close_instances_ok": "全部關閉並安裝", "app.about.download_progress.ready_to_restart": "下載完成(100%)。點選「重新啟動應用更新」即可完成安裝。", "app.about.download_progress.ready_to_install": "MSI 安裝套件下載完成(100%),可以開始安裝。", "app.about.download_progress.downloading": "正在下載更新…", diff --git a/tools/windows-release-artifacts.test.py b/tools/windows-release-artifacts.test.py index cac96736..0e646437 100644 --- a/tools/windows-release-artifacts.test.py +++ b/tools/windows-release-artifacts.test.py @@ -33,6 +33,15 @@ class WindowsReleaseArtifactsTest(unittest.TestCase): self.assertIn("-arch $wixArch", source) self.assertIn("TestExpectedAssetNameForWindowsInstallMode", source) self.assertIn("TestInstallUpdateAndRestartMSI", source) + self.assertIn("TestShouldEnableWindowsMSISingleInstanceOnlyForInstalledMainGUI", source) + self.assertIn("TestAcquireWindowsMSISingleInstance", source) + self.assertIn("TestAcquireWindowsUpdateMaintenance", source) + self.assertIn("TestPrepareWindowsUpdateHandoff", source) + self.assertIn("TestWindowsUpdateRequiresExplicitCloseConfirmation", source) + self.assertIn("TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows", source) + self.assertIn("TestFindOtherWindowsUpdateInstances", source) + self.assertIn("TestCloseWindowsUpdateInstances", source) + self.assertIn("TestInstallUpdateAndRestartClosesOtherTargetInstances", source) self.assertIn("TestBuildWindowsMSIUpdatePowerShellScript", source) self.assertIn('-d "ProductName=GoNavi"', source) self.assertIn(f'-d "UpgradeCode={UPGRADE_CODE}"', source)