diff --git a/CHANGELOG.md b/CHANGELOG.md index 8772fc6c..a3145230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ ## [Unreleased] +## [0.0.9] - 2026-10-02 + +### Fixed + +- After an in-app desktop download completes, restart the native FreeOS shell instead of restarting only the Python service, so the staged portable runtime is actually applied before the new version starts. +- Wait for the previous Windows shell process to exit before the replacement instance claims the single-instance lock, preventing the relaunch from being mistaken for a duplicate process. +- Keep the staged portable archive and metadata until extraction, validation, and runtime replacement all succeed, so a failed swap rolls back to the working runtime and retries on the next launch instead of silently losing the update. + ## [0.0.8] - 2026-09-30 ### Fixed diff --git a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx index 3b437928..387666d2 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx +++ b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx @@ -22,6 +22,7 @@ import { clearStoredUpdateStatus, storeUpdateStatus, } from "../../../utils/updateStatusCache"; +import { restartDesktopForUpdate } from "../../../utils/desktopChrome"; import { TabPanelHeader } from "./TabPanelHeader"; import styles from "./UpdateConfig.module.less"; @@ -145,6 +146,7 @@ export default function UpdateConfig() { if (progress?.status !== "complete" || !status?.desktop) return; if (restartPhase !== "idle" || autoRestartedRef.current) return; autoRestartedRef.current = true; + if (restartDesktopForUpdate()) return; void executeRestart(); }, [progress?.status, status?.desktop, restartPhase, executeRestart]); diff --git a/dashboard/src/utils/desktopChrome.test.ts b/dashboard/src/utils/desktopChrome.test.ts index 0603e191..1c30266f 100644 --- a/dashboard/src/utils/desktopChrome.test.ts +++ b/dashboard/src/utils/desktopChrome.test.ts @@ -6,6 +6,7 @@ import { CHROME_END_PAD_ATTR, chromeEndPadValue, emitDesktopWindowAction, + restartDesktopForUpdate, installDesktopWindowDrag, isDesktopShell, resolveDesktopChromeStyle, @@ -130,6 +131,17 @@ describe("desktopChrome", () => { expect(invoke).toHaveBeenCalledWith("wails:event:emit:desktop:minimise"); }); + it("asks the native shell to relaunch for a staged update", () => { + const invoke = vi.fn(); + expect(restartDesktopForUpdate({ _wails: { invoke } } as Window)).toBe( + true, + ); + expect(invoke).toHaveBeenCalledWith( + "wails:event:emit:desktop:restart-for-update", + ); + expect(restartDesktopForUpdate({} as Window)).toBe(false); + }); + it("arms drag on --wails-draggable regions and the top chrome strip", () => { const region = document.createElement("div"); region.style.setProperty("--wails-draggable", "drag"); diff --git a/dashboard/src/utils/desktopChrome.ts b/dashboard/src/utils/desktopChrome.ts index e4611504..62905238 100644 --- a/dashboard/src/utils/desktopChrome.ts +++ b/dashboard/src/utils/desktopChrome.ts @@ -5,6 +5,9 @@ type DesktopWindow = Window & { export type DesktopChromeStyle = "mac" | "windows"; export type DesktopWindowAction = "minimise" | "toggle-maximise" | "close"; +const DESKTOP_UPDATE_RESTART_EVENT = + "wails:event:emit:desktop:restart-for-update"; + export const WINDOW_CONTROLS_INSET: Record = { // 3×12px lights + 2×8px gaps + 8px/12px padding, plus a little slack. mac: 80, @@ -88,6 +91,16 @@ export function emitDesktopWindowAction( return true; } +/** Ask the native shell to fully relaunch so it can apply a staged runtime update. */ +export function restartDesktopForUpdate( + win: Pick = window as DesktopWindow, +): boolean { + const invoke = win._wails?.invoke; + if (typeof invoke !== "function") return false; + invoke(DESKTOP_UPDATE_RESTART_EVENT); + return true; +} + function dragEventElement(target: EventTarget | null): Element | null { if (target instanceof Element) return target; if (target instanceof Node) return target.parentElement; diff --git a/desktop/src/download.go b/desktop/src/download.go index a19732b2..194f4db2 100644 --- a/desktop/src/download.go +++ b/desktop/src/download.go @@ -40,6 +40,7 @@ func pythonExe(root string) string { func ensurePortable(locale Locale, status func(string)) error { root := portableDir() + pendingUpdate := pendingPortableZip() != "" if launchReady(root) { if !shouldReplacePortable(root) { status(desktopText(locale, copyStatusUsingRuntime)) @@ -75,6 +76,9 @@ func ensurePortable(locale Locale, status func(string)) error { if !launchReady(root) { return fmt.Errorf("portable extract missing launch.py or python under %s", root) } + if pendingUpdate { + clearPendingPortable() + } recordAppliedInstallStamp(root) return nil } @@ -630,7 +634,6 @@ func extractPortable(root string) error { if err := unzipGreen(pending, root); err != nil { return err } - clearPendingPortable() return nil } if os.Getenv("OCTOP_DESKTOP_PORTABLE_ZIP") != "" { diff --git a/desktop/src/download_test.go b/desktop/src/download_test.go index e4e2d538..aeb62c9e 100644 --- a/desktop/src/download_test.go +++ b/desktop/src/download_test.go @@ -251,6 +251,52 @@ func TestEnsurePortableAppliesPendingFreeOSZip(t *testing.T) { } } +func TestEnsurePortableKeepsPendingZipWhenSwapFails(t *testing.T) { + home := t.TempDir() + t.Setenv("OCTOP_HOME", home) + root := portableDir() + + oldZip := filepath.Join(t.TempDir(), "old.zip") + writeTestGreenZip(t, oldZip, "0.0.1") + if err := unzipGreen(oldZip, root); err != nil { + t.Fatal(err) + } + + pendingDir := filepath.Join(home, "updates") + if err := os.MkdirAll(pendingDir, 0o755); err != nil { + t.Fatal(err) + } + pending := filepath.Join(pendingDir, "pending-portable.zip") + writeTestGreenZipWithStamp(t, pending, "0.0.2", "github-pending") + if err := os.WriteFile(filepath.Join(pendingDir, "pending.json"), []byte(`{"version":"0.0.2"}`), 0o644); err != nil { + t.Fatal(err) + } + + prevRename := renamePortableImpl + prevStop := stopPortableHoldersFn + stopPortableHoldersFn = func(string) {} + renamePortableImpl = func(source, target string) error { + if strings.HasSuffix(source, ".new") && target == root { + return errors.New("forced swap failure") + } + return os.Rename(source, target) + } + t.Cleanup(func() { + renamePortableImpl = prevRename + stopPortableHoldersFn = prevStop + }) + + if err := ensurePortable(LocaleZH, func(string) {}); err != nil { + t.Fatalf("existing runtime should remain available: %v", err) + } + if _, err := os.Stat(pending); err != nil { + t.Fatalf("pending zip must remain available for retry: %v", err) + } + if got := portableVersion(root); got != "0.0.1" { + t.Fatalf("portable version = %q, want preserved 0.0.1", got) + } +} + func TestEnsurePortableRejectsMislabeledPendingZip(t *testing.T) { home := t.TempDir() t.Setenv("OCTOP_HOME", home) diff --git a/desktop/src/main.go b/desktop/src/main.go index 656342e8..651d95d2 100644 --- a/desktop/src/main.go +++ b/desktop/src/main.go @@ -8,6 +8,7 @@ import ( "log" "os" "os/exec" + "path/filepath" "runtime" "strconv" "strings" @@ -389,6 +390,48 @@ func (a *App) requestQuit() { } } +const restartAfterPIDPrefix = "--freeos-restart-after-pid=" + +func restartParentPID(args []string) int { + for _, arg := range args { + if !strings.HasPrefix(arg, restartAfterPIDPrefix) { + continue + } + pid, err := strconv.Atoi(strings.TrimPrefix(arg, restartAfterPIDPrefix)) + if err == nil && pid > 0 { + return pid + } + } + return 0 +} + +func waitForRestartParent(args []string) { + pid := restartParentPID(args) + if pid <= 0 { + return + } + deadline := time.Now().Add(20 * time.Second) + for pidAlive(pid) && time.Now().Before(deadline) { + time.Sleep(100 * time.Millisecond) + } +} + +func (a *App) restartForUpdate() { + exe, err := os.Executable() + if err != nil { + log.Printf("restart for update: resolve executable: %v", err) + return + } + cmd := exec.Command(exe, fmt.Sprintf("%s%d", restartAfterPIDPrefix, os.Getpid())) + cmd.Dir = filepath.Dir(exe) + if err := cmd.Start(); err != nil { + log.Printf("restart for update: launch replacement: %v", err) + return + } + log.Printf("restart for update: replacement pid=%d", cmd.Process.Pid) + a.requestQuit() +} + func main() { defer func() { if rec := recover(); rec != nil { @@ -396,6 +439,7 @@ func main() { showFatalError("FreeOS", desktopText(LocaleEN, copyNavigateFailed)) } }() + waitForRestartParent(os.Args[1:]) pinWorkingDirectory() if err := ensureProductHomeWritable(); err != nil { showFatalError("FreeOS", err.Error()) @@ -488,6 +532,9 @@ func main() { log.Printf("desktop:close event") api.hideToTray() }) + app.Event.On("desktop:restart-for-update", func(_ *application.CustomEvent) { + go api.restartForUpdate() + }) app.Event.On("desktop:select-folder", func(_ *application.CustomEvent) { go api.emitPickedFolder() }) diff --git a/desktop/src/update_restart_test.go b/desktop/src/update_restart_test.go new file mode 100644 index 00000000..5e702fcf --- /dev/null +++ b/desktop/src/update_restart_test.go @@ -0,0 +1,23 @@ +package main + +import "testing" + +func TestRestartParentPID(t *testing.T) { + tests := []struct { + name string + args []string + want int + }{ + {name: "missing", args: []string{"--other"}, want: 0}, + {name: "valid", args: []string{"--other", restartAfterPIDPrefix + "4321"}, want: 4321}, + {name: "invalid", args: []string{restartAfterPIDPrefix + "bad"}, want: 0}, + {name: "zero", args: []string{restartAfterPIDPrefix + "0"}, want: 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := restartParentPID(tt.args); got != tt.want { + t.Fatalf("restartParentPID() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/pyproject.toml b/pyproject.toml index 46e2b48e..06bc2cbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "octop" -version = "0.0.8" +version = "0.0.9" description = "FreeOS — self-hosted multi-user multi-agent assistant with an optional organization OS module (Octop-compatible package name)" readme = "README.md" license = { text = "MIT" } diff --git a/src/octop/__init__.py b/src/octop/__init__.py index 4eeceefc..a9f39bcc 100644 --- a/src/octop/__init__.py +++ b/src/octop/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.0.8" +__version__ = "0.0.9" diff --git a/uv.lock b/uv.lock index 381da5bc..6d59143c 100644 --- a/uv.lock +++ b/uv.lock @@ -2503,7 +2503,7 @@ wheels = [ [[package]] name = "octop" -version = "0.0.8" +version = "0.0.9" source = { editable = "." } dependencies = [ { name = "acme" },