("login");
+ const desktop = isDesktopShell(`?${searchParams.toString()}`);
useEffect(() => {
void applyGuestLocale();
@@ -27,7 +30,9 @@ export default function LoginPage() {
useEffect(() => {
let cancelled = false;
const boot = async () => {
- for (let attempt = 0; attempt < 4; attempt += 1) {
+ const attempts = desktop ? 20 : 4;
+ const delayMs = desktop ? 250 : 150;
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const session = await authApi.localSession();
setAuthToken(session.access_token);
@@ -35,13 +40,29 @@ export default function LoginPage() {
if (!cancelled) navigate("/chat", { replace: true });
return;
} catch {
- if (attempt < 3) {
+ if (attempt < attempts - 1) {
await new Promise((resolve) => {
- window.setTimeout(resolve, 150);
+ window.setTimeout(resolve, delayMs);
});
}
}
}
+ if (desktop) {
+ while (!cancelled) {
+ try {
+ const session = await authApi.localSession();
+ setAuthToken(session.access_token);
+ await applyUserLocale(session.user.locale);
+ if (!cancelled) navigate("/chat", { replace: true });
+ return;
+ } catch {
+ await new Promise((resolve) => {
+ window.setTimeout(resolve, 400);
+ });
+ }
+ }
+ return;
+ }
try {
const status = await authApi.getAuthStatus();
if (cancelled) return;
@@ -60,7 +81,7 @@ export default function LoginPage() {
return () => {
cancelled = true;
};
- }, [navigate]);
+ }, [desktop, navigate]);
useEffect(() => {
const code = searchParams.get("oidc_error");
@@ -77,6 +98,22 @@ export default function LoginPage() {
navigate("/chat", { replace: true });
};
+ if (desktop) {
+ return (
+
+
+
+ );
+ }
+
return (
{
+ afterEach(() => {
+ sessionStorage.clear();
+ });
+
+ it("detects the desktop query and remembers it after the query is dropped", () => {
+ expect(isDesktopShell("?desktop=1")).toBe(true);
+ expect(isDesktopShell("")).toBe(true);
+ });
+
+ it("is false without the query or a remembered flag", () => {
+ expect(isDesktopShell("")).toBe(false);
+ expect(isDesktopShell("?foo=1")).toBe(false);
+ });
+});
diff --git a/dashboard/src/utils/desktopShell.ts b/dashboard/src/utils/desktopShell.ts
new file mode 100644
index 00000000..f7538b72
--- /dev/null
+++ b/dashboard/src/utils/desktopShell.ts
@@ -0,0 +1,25 @@
+const DESKTOP_FLAG = "freeos:desktop-shell";
+
+/** True when the SPA is inside the Wails shell (`?desktop=1` or remembered). */
+export function isDesktopShell(search?: string): boolean {
+ const query =
+ search ?? (typeof window === "undefined" ? "" : window.location.search);
+ if (new URLSearchParams(query).get("desktop") === "1") {
+ if (typeof window !== "undefined") {
+ try {
+ window.sessionStorage.setItem(DESKTOP_FLAG, "1");
+ } catch {
+ // Ignore quota / private-mode failures; the query string still counts.
+ }
+ }
+ return true;
+ }
+ if (typeof window === "undefined") {
+ return false;
+ }
+ try {
+ return window.sessionStorage.getItem(DESKTOP_FLAG) === "1";
+ } catch {
+ return false;
+ }
+}
diff --git a/desktop/src/desktop_copy.go b/desktop/src/desktop_copy.go
index eeb61763..10b8a0d7 100644
--- a/desktop/src/desktop_copy.go
+++ b/desktop/src/desktop_copy.go
@@ -25,6 +25,8 @@ const (
copyHealthNotReady = "health.not_ready"
copySeeDesktopLog = "error.see_desktop_log"
copyAlreadyRunning = "error.already_running"
+ copyNavigateFailed = "error.navigate_failed"
+ copyHealthForeignHost = "health.foreign_host"
)
var desktopCopy = map[Locale]map[string]string{
@@ -51,6 +53,8 @@ var desktopCopy = map[Locale]map[string]string{
copyHealthNotReady: "FreeOS did not become ready within %s (%s). Make sure FreeOS is running at this address, or check logs/desktop.log.",
copySeeDesktopLog: "Details were written to %s",
copyAlreadyRunning: "FreeOS is already running. Check the system tray, or quit the existing process and try again.",
+ copyNavigateFailed: "FreeOS could not open the window. Close any leftover FreeOS or Octop process, delete %LOCALAPPDATA%\\FreeOS\\WebView2 if it exists, then open FreeOS again. Details are in logs/desktop.log.",
+ copyHealthForeignHost: "FreeOS did not become ready within %s (%s). A leftover service on that address is not FreeOS. Quit the old process or reinstall, then try again.",
},
LocaleZH: {
copyStatusConnecting: "正在连接 FreeOS…",
@@ -75,6 +79,8 @@ var desktopCopy = map[Locale]map[string]string{
copyHealthNotReady: "FreeOS 服务未在%s内就绪(%s)。请确认本机已启动 FreeOS,且地址、端口正确;也可查看 logs/desktop.log。",
copySeeDesktopLog: "详细日志已写入 %s",
copyAlreadyRunning: "FreeOS 已在运行。请查看系统托盘,或退出已有进程后再试。",
+ copyNavigateFailed: "FreeOS 无法打开窗口。请退出残留的 FreeOS / Octop 进程,必要时删除 %LOCALAPPDATA%\\FreeOS\\WebView2,然后重新打开。详情见 logs/desktop.log。",
+ copyHealthForeignHost: "FreeOS 服务未在%s内就绪(%s)。该地址上已有服务但不是 FreeOS。请退出旧进程或重新安装后再试。",
},
}
diff --git a/desktop/src/download.go b/desktop/src/download.go
index 847c70c9..d024c4a1 100644
--- a/desktop/src/download.go
+++ b/desktop/src/download.go
@@ -3,8 +3,11 @@ package main
import (
"archive/zip"
"bytes"
+ "encoding/json"
+ "errors"
"fmt"
"io"
+ "log"
"net/http"
"os"
"os/exec"
@@ -55,10 +58,14 @@ func ensurePortable(locale Locale, status func(string)) error {
status(desktopText(locale, copyStatusFirstExtract))
}
if err := replacePortable(root); err != nil {
- if launchReady(root) {
+ if launchReady(root) && !isOctopLineageRuntime(root) && installedPortableStamp(root) != "" {
status(desktopText(locale, copyStatusUpdateFailedKeep))
return nil
}
+ if isOctopLineageRuntime(root) || (launchReady(root) && installedPortableStamp(root) == "") {
+ log.Printf("discarding leftover Octop portable at %s after failed refresh", root)
+ _ = os.RemoveAll(root)
+ }
return err
}
if runtime.GOOS == "darwin" {
@@ -509,6 +516,67 @@ func unzipGreenFiles(files []*zip.File, dest string) error {
return nil
}
+var errForeignHost = errors.New("host is not FreeOS")
+
+type healthPayload struct {
+ OK bool `json:"ok"`
+ Product string `json:"product"`
+}
+
+func isFreeOSHealth(body []byte) bool {
+ var payload healthPayload
+ if json.Unmarshal(body, &payload) != nil {
+ return false
+ }
+ return payload.OK && strings.EqualFold(strings.TrimSpace(payload.Product), "freeos")
+}
+
+func probeHealthURL(base string) (reachable bool, freeos bool) {
+ resp, err := http.Get(strings.TrimRight(base, "/") + "/api/health")
+ if err != nil {
+ return false, false
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 500 {
+ return false, false
+ }
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ return true, isFreeOSHealth(body)
+}
+
+func chooseHostPort(preferred int) int {
+ if preferred <= 0 {
+ preferred = 8088
+ }
+ for port := preferred; port < preferred+20; port++ {
+ reachable, _ := probeHealthURL(dashboardURL(port))
+ if reachable {
+ continue
+ }
+ return port
+ }
+ return preferred
+}
+
+func discardStaleOctopPortable() {
+ home := userProfileDir()
+ if home == "" {
+ return
+ }
+ legacy := filepath.Join(home, ".octop", "portable")
+ if filepath.Clean(legacy) == filepath.Clean(portableDir()) {
+ return
+ }
+ if _, err := os.Stat(legacy); err != nil {
+ return
+ }
+ if installedPortableStamp(legacy) != "" {
+ return
+ }
+ log.Printf("discarding leftover Octop portable at %s", legacy)
+ _ = os.RemoveAll(legacy)
+}
+
func waitHealth(locale Locale, base string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
url := strings.TrimRight(base, "/") + "/api/health"
@@ -518,10 +586,15 @@ func waitHealth(locale Locale, base string, timeout time.Duration) error {
resp, err := http.Get(url)
if err == nil {
lastStatus = resp.StatusCode
- lastErr = nil
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 500 {
- return nil
+ if isFreeOSHealth(body) {
+ return nil
+ }
+ lastErr = errForeignHost
+ } else {
+ lastErr = nil
}
} else {
lastErr = err
@@ -560,6 +633,8 @@ func formatHealthWaitError(locale Locale, base string, timeout time.Duration, la
switch {
case lastStatus >= 500:
return fmt.Errorf("%s", desktopText(locale, copyHealthNotReady5xx, wait, addr))
+ case errors.Is(lastErr, errForeignHost):
+ return fmt.Errorf("%s", desktopText(locale, copyHealthForeignHost, wait, addr))
case lastErr != nil:
return fmt.Errorf("%s", desktopText(locale, copyHealthNotReadyConnect, wait, addr))
default:
diff --git a/desktop/src/download_test.go b/desktop/src/download_test.go
index 05f6d8bd..d9247f51 100644
--- a/desktop/src/download_test.go
+++ b/desktop/src/download_test.go
@@ -3,6 +3,7 @@ package main
import (
"archive/zip"
"errors"
+ "net"
"net/http"
"net/http/httptest"
"os"
@@ -309,30 +310,55 @@ func TestEnsurePortableKeepsCurrentRuntimeWhenReplacementIsInvalid(t *testing.T)
root := portableDir()
currentZip := filepath.Join(t.TempDir(), "current.zip")
- writeTestGreenZip(t, currentZip, "0.9.31")
+ writeTestGreenZip(t, currentZip, "0.0.1")
if err := unzipGreen(currentZip, root); err != nil {
t.Fatal(err)
}
invalidZip := filepath.Join(t.TempDir(), "invalid.zip")
- writeVersionOnlyZip(t, invalidZip, "0.9.32")
+ writeVersionOnlyZip(t, invalidZip, "0.0.2")
t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", invalidZip)
var statuses []string
if err := ensurePortable(LocaleZH, func(status string) { statuses = append(statuses, status) }); err != nil {
- t.Fatalf("existing runtime should still boot after a failed replacement: %v", err)
+ t.Fatalf("existing FreeOS runtime should still boot after a failed replacement: %v", err)
}
if !launchReady(root) {
t.Fatal("current runtime should remain usable after replacement failure")
}
- if got := portableVersion(root); got != "0.9.31" {
- t.Fatalf("portable version = %q, want 0.9.31", got)
+ if got := portableVersion(root); got != "0.0.1" {
+ t.Fatalf("portable version = %q, want 0.0.1", got)
+ }
+ if installedPortableStamp(root) == "" {
+ t.Fatal("kept runtime should still carry FREEOS_STAMP")
}
if len(statuses) == 0 || statuses[len(statuses)-1] != "更新内置运行环境失败,继续使用已有运行环境…" {
t.Fatalf("unexpected statuses: %v", statuses)
}
}
+func TestEnsurePortableDoesNotKeepOctopAfterFailedRefresh(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("OCTOP_HOME", home)
+ root := portableDir()
+
+ currentZip := filepath.Join(t.TempDir(), "current.zip")
+ writeTestGreenZipNoStamp(t, currentZip, "0.9.31")
+ if err := unzipGreen(currentZip, root); err != nil {
+ t.Fatal(err)
+ }
+
+ invalidZip := filepath.Join(t.TempDir(), "invalid.zip")
+ writeVersionOnlyZip(t, invalidZip, "0.0.1")
+ t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", invalidZip)
+ if err := ensurePortable(LocaleZH, func(string) {}); err == nil {
+ t.Fatal("Octop leftover should not be kept after a failed FreeOS refresh")
+ }
+ if launchReady(root) {
+ t.Fatal("Octop leftover portable should have been discarded")
+ }
+}
+
func TestEnsurePortableReplacesLegacyRuntimeWithoutVersionFiles(t *testing.T) {
home := t.TempDir()
t.Setenv("OCTOP_HOME", home)
@@ -470,7 +496,8 @@ func TestFormatHealthWaitErrorUsesServiceNotReadyHintOn5xx(t *testing.T) {
func TestWaitHealthSucceedsOnOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusOK)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"ok":true,"product":"freeos"}`))
}))
t.Cleanup(srv.Close)
if err := waitHealth(LocaleZH, srv.URL, time.Second); err != nil {
@@ -478,6 +505,70 @@ func TestWaitHealthSucceedsOnOK(t *testing.T) {
}
}
+func TestWaitHealthRejectsOctopPayload(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"ok":true}`))
+ }))
+ t.Cleanup(srv.Close)
+ err := waitHealth(LocaleEN, srv.URL, 80*time.Millisecond)
+ if err == nil {
+ t.Fatal("legacy Octop health should not count as ready")
+ }
+ if !strings.Contains(err.Error(), "not FreeOS") {
+ t.Fatalf("foreign host error = %v", err)
+ }
+}
+
+func TestChooseHostPortSkipsOccupied(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"ok":true}`))
+ }))
+ t.Cleanup(srv.Close)
+ addr, ok := srv.Listener.Addr().(*net.TCPAddr)
+ if !ok {
+ t.Fatal("expected tcp addr")
+ }
+ got := chooseHostPort(addr.Port)
+ if got == addr.Port {
+ t.Fatalf("chooseHostPort returned occupied port %d", got)
+ }
+}
+
+func TestIsFreeOSHealth(t *testing.T) {
+ if !isFreeOSHealth([]byte(`{"ok":true,"product":"FreeOS"}`)) {
+ t.Fatal("expected FreeOS health")
+ }
+ if isFreeOSHealth([]byte(`{"ok":true}`)) {
+ t.Fatal("Octop payload must not look like FreeOS")
+ }
+ if isFreeOSHealth([]byte(`not-json`)) {
+ t.Fatal("garbage must not look like FreeOS")
+ }
+}
+
+func TestDiscardStaleOctopPortable(t *testing.T) {
+ root := t.TempDir()
+ t.Setenv("HOME", root)
+ t.Setenv("USERPROFILE", root)
+ t.Setenv("FREEOS_HOME", filepath.Join(root, ".freeos"))
+ t.Setenv("OCTOP_HOME", "")
+ legacy := filepath.Join(root, ".octop", "portable")
+ oldZip := filepath.Join(t.TempDir(), "octop.zip")
+ writeTestGreenZipNoStamp(t, oldZip, "1.0.0")
+ if err := unzipGreen(oldZip, legacy); err != nil {
+ t.Fatal(err)
+ }
+ if !launchReady(legacy) {
+ t.Fatal("fixture should be launch-ready")
+ }
+ discardStaleOctopPortable()
+ if _, err := os.Stat(legacy); !os.IsNotExist(err) {
+ t.Fatalf("stale Octop portable still present: %v", err)
+ }
+}
+
func TestWaitHealthTimesOutWithFriendlyMessage(t *testing.T) {
err := waitHealth(LocaleEN, "http://127.0.0.1:1", 50*time.Millisecond)
if err == nil {
diff --git a/desktop/src/launch_paths.go b/desktop/src/launch_paths.go
index 674e8bb1..722e2636 100644
--- a/desktop/src/launch_paths.go
+++ b/desktop/src/launch_paths.go
@@ -52,6 +52,46 @@ func webviewUserDataPath() string {
return filepath.Join(base, "FreeOS", "WebView2")
}
+func webviewUserDataCandidates() []string {
+ primary := webviewUserDataPath()
+ if primary == "" {
+ return nil
+ }
+ out := []string{primary}
+ fallback := filepath.Join(productHome(), "WebView2")
+ if filepath.Clean(fallback) != filepath.Clean(primary) {
+ out = append(out, fallback)
+ }
+ return out
+}
+
+func dirIsWritable(dir string) bool {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return false
+ }
+ probe := filepath.Join(dir, ".write-probe")
+ if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
+ return false
+ }
+ _ = os.Remove(probe)
+ return true
+}
+
+func prepareWebviewUserData() string {
+ for _, path := range webviewUserDataCandidates() {
+ if !dirIsWritable(path) {
+ log.Printf("webview user data not writable: %s", path)
+ continue
+ }
+ if err := os.Setenv("WEBVIEW2_USER_DATA_FOLDER", path); err != nil {
+ log.Printf("set WEBVIEW2_USER_DATA_FOLDER: %v", err)
+ }
+ log.Printf("webview user data: %s", path)
+ return path
+ }
+ return ""
+}
+
func ensureProductHomeWritable() error {
home := productHome()
if err := os.MkdirAll(home, 0o755); err != nil {
diff --git a/desktop/src/launch_paths_test.go b/desktop/src/launch_paths_test.go
index 5b043e37..2d1bebb8 100644
--- a/desktop/src/launch_paths_test.go
+++ b/desktop/src/launch_paths_test.go
@@ -33,6 +33,31 @@ func TestWebviewUserDataPathWindowsLayout(t *testing.T) {
}
}
+func TestPrepareWebviewUserDataLinuxIsEmpty(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("windows uses a real user-data path")
+ }
+ if got := prepareWebviewUserData(); got != "" {
+ t.Fatalf("prepareWebviewUserData() = %q", got)
+ }
+}
+
+func TestPrepareWebviewUserDataWindowsSetsEnv(t *testing.T) {
+ if runtime.GOOS != "windows" {
+ t.Skip("WEBVIEW2_USER_DATA_FOLDER is a Windows runtime concern")
+ }
+ local := filepath.Join(t.TempDir(), "local")
+ t.Setenv("LOCALAPPDATA", local)
+ got := prepareWebviewUserData()
+ want := filepath.Join(local, "FreeOS", "WebView2")
+ if got != want {
+ t.Fatalf("prepareWebviewUserData() = %q, want %q", got, want)
+ }
+ if os.Getenv("WEBVIEW2_USER_DATA_FOLDER") != want {
+ t.Fatalf("WEBVIEW2_USER_DATA_FOLDER = %q", os.Getenv("WEBVIEW2_USER_DATA_FOLDER"))
+ }
+}
+
func TestEnsureProductHomeWritableCreatesDir(t *testing.T) {
root := t.TempDir()
home := filepath.Join(root, "nested-home")
diff --git a/desktop/src/main.go b/desktop/src/main.go
index 13a7a096..7dbf8fcc 100644
--- a/desktop/src/main.go
+++ b/desktop/src/main.go
@@ -36,6 +36,9 @@ type App struct {
trayClickMu sync.Mutex
lastTrayClick time.Time
trayClickTimer *time.Timer
+
+ webviewReady chan struct{}
+ webviewReadyOnce sync.Once
}
func (a *App) ServiceName() string { return "desktop" }
@@ -177,6 +180,7 @@ func (a *App) boot() {
}
s := a.store.get()
a.setStatus(desktopText(locale, copyStatusCheckingRuntime))
+ discardStaleOctopPortable()
firstLaunch := !launchReady(portableDir())
if err := ensurePortable(locale, a.setStatus); err != nil {
logStartupError("portable runtime", err)
@@ -185,18 +189,22 @@ func (a *App) boot() {
return
}
root := portableDir()
+ port := chooseHostPort(s.Port)
+ if port != s.Port {
+ log.Printf("port %d is occupied; starting FreeOS host on %d", s.Port, port)
+ }
a.mu.Lock()
stopOctop(a.cmd)
stopOctop(a.sidecar)
if sidecarReady(root) {
a.setStatus(desktopText(locale, copyStatusStartingOrg))
- sidecar, serr := startOrgSidecar(root, s.Port)
+ sidecar, serr := startOrgSidecar(root, port)
a.sidecar = sidecar
if serr != nil {
logStartupError("organization sidecar", serr)
}
}
- cmd, err := startOctop(root, s.Port)
+ cmd, err := startOctop(root, port)
a.cmd = cmd
a.mu.Unlock()
if err != nil {
@@ -205,7 +213,7 @@ func (a *App) boot() {
showFatalError("FreeOS", formatFatalStartup(locale, err))
return
}
- base := dashboardURL(s.Port)
+ base := dashboardURL(port)
a.setStatus(desktopText(locale, copyStatusStartingService))
timeout := 2 * time.Minute
if firstLaunch {
@@ -224,7 +232,20 @@ func (a *App) showDashboard(base string) {
if a.window == nil {
return
}
- a.window.SetURL(withDesktopQuery(base))
+ if !a.waitWebviewReady(20 * time.Second) {
+ log.Printf("webview not ignited after wait; retrying SetURL")
+ }
+ url := withDesktopQuery(base)
+ if err := navigateWhenReady(func(next string) { a.window.SetURL(next) }, url, 8); err != nil {
+ logStartupError("navigate dashboard", err)
+ locale := LocaleEN
+ if a.store != nil {
+ locale = a.store.get().Locale
+ }
+ a.setStatus(desktopText(locale, copyNavigateFailed))
+ showFatalError("FreeOS", desktopText(locale, copyNavigateFailed))
+ return
+ }
a.scheduleDragOverlay()
s := a.store.get()
go func() {
@@ -314,7 +335,8 @@ func (a *App) requestQuit() {
func main() {
defer func() {
if rec := recover(); rec != nil {
- showFatalError("FreeOS", fmt.Sprintf("%v", rec))
+ log.Printf("panic: %v", rec)
+ showFatalError("FreeOS", desktopText(LocaleEN, copyNavigateFailed))
}
}()
pinWorkingDirectory()
@@ -327,11 +349,7 @@ func main() {
cwd, _ := os.Getwd()
log.Printf("launch exe=%s cwd=%s home=%s", exe, cwd, productHome())
}
- if p := webviewUserDataPath(); p != "" {
- if err := os.MkdirAll(p, 0o755); err != nil {
- log.Printf("webview user data dir: %v", err)
- }
- }
+ webviewData := prepareWebviewUserData()
if claimDesktopInstance() {
locale := LocaleEN
if data, err := os.ReadFile(settingsPath()); err == nil {
@@ -351,8 +369,9 @@ func main() {
store := &settingsStore{cur: loadSettings()}
api := &App{
- store: store,
- sleep: &sleepGuard{},
+ store: store,
+ sleep: &sleepGuard{},
+ webviewReady: make(chan struct{}),
}
app := application.New(application.Options{
@@ -366,11 +385,11 @@ func main() {
},
Windows: application.WindowsOptions{
DisableQuitOnLastWindowClosed: true,
- WebviewUserDataPath: webviewUserDataPath(),
+ WebviewUserDataPath: webviewData,
},
PanicHandler: func(details *application.PanicDetails) {
log.Printf("panic: %+v", details)
- showFatalError("FreeOS", fmt.Sprintf("%v", details))
+ showFatalError("FreeOS", desktopText(api.store.get().Locale, copyNavigateFailed))
},
ErrorHandler: func(err error) {
logStartupError("wails", err)
@@ -408,25 +427,25 @@ func main() {
app.Event.On("desktop:close", func(_ *application.CustomEvent) {
api.hideToTray()
})
- installDragOverlay := func(_ *application.WindowEvent) { api.scheduleDragOverlay() }
- win.OnWindowEvent(events.Mac.WebViewDidFinishNavigation, installDragOverlay)
- win.OnWindowEvent(events.Windows.WebViewNavigationCompleted, installDragOverlay)
- win.OnWindowEvent(events.Linux.WindowLoadFinished, installDragOverlay)
- settingsWin := app.Window.NewWithOptions(application.WebviewWindowOptions{
- Title: "FreeOS 设置",
- Width: settingsWindowWidth,
- Height: settingsWindowOuterHeight(),
- URL: "/?settings=1",
- Hidden: true,
- Frameless: true,
- AlwaysOnTop: true,
- DisableResize: true,
- BackgroundColour: application.NewRGB(255, 255, 255),
- Windows: application.WindowsWindow{
- HiddenOnTaskbar: true,
- },
- })
- api.settingsWindow = settingsWin
+ tray := app.SystemTray.New()
+ applyTrayIcon(tray)
+ tray.SetTooltip("FreeOS")
+ var settingsOnce sync.Once
+ ensureSettings := func() {
+ settingsOnce.Do(func() {
+ settingsWin := newSettingsWindow(app)
+ api.settingsWindow = settingsWin
+ tray.AttachWindow(settingsWin).WindowOffset(6)
+ })
+ }
+ onMainNavigated := func(_ *application.WindowEvent) {
+ api.markWebviewReady()
+ api.scheduleDragOverlay()
+ ensureSettings()
+ }
+ win.OnWindowEvent(events.Mac.WebViewDidFinishNavigation, onMainNavigated)
+ win.OnWindowEvent(events.Windows.WebViewNavigationCompleted, onMainNavigated)
+ win.OnWindowEvent(events.Linux.WindowLoadFinished, onMainNavigated)
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(event *application.ApplicationEvent) {
event.Cancel()
restoreMainAfterDockClick(api.window, api.settingsWindow)
@@ -447,19 +466,10 @@ func main() {
api.hideToTray()
}
})
- settingsWin.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
- e.Cancel()
- settingsWin.Hide()
- })
- settingsWin.OnWindowEvent(events.Common.WindowLostFocus, func(_ *application.WindowEvent) {
- settingsWin.Hide()
- })
-
- tray := app.SystemTray.New()
- applyTrayIcon(tray)
- tray.SetTooltip("FreeOS")
- tray.AttachWindow(settingsWin).WindowOffset(6)
- showSettings := func() { tray.ShowWindow() }
+ showSettings := func() {
+ ensureSettings()
+ tray.ShowWindow()
+ }
if trayLeftClickShowsSettings(runtime.GOOS) {
tray.OnClick(showSettings)
} else {
diff --git a/desktop/src/settings.go b/desktop/src/settings.go
index abdeae00..21a44863 100644
--- a/desktop/src/settings.go
+++ b/desktop/src/settings.go
@@ -16,7 +16,7 @@ const (
)
// Settings is persisted at ~/.freeos/desktop-settings.json
-// (legacy ~/.octop/desktop-settings.json is still honored when that home wins).
+// (OCTOP_HOME still overrides when explicitly set).
type Settings struct {
Locale Locale `json:"locale"`
Autostart bool `json:"autostart"`
@@ -46,15 +46,9 @@ func productHome() string {
if home == "" {
return filepath.Join(os.TempDir(), "FreeOS")
}
- freeos := filepath.Join(home, ".freeos")
- octop := filepath.Join(home, ".octop")
- if _, err := os.Stat(freeos); err == nil {
- return freeos
- }
- if _, err := os.Stat(octop); err == nil {
- return octop
- }
- return freeos
+ // Installed FreeOS always owns ~/.freeos. A leftover ~/.octop from Octop
+ // must not become the desktop home — that re-serves the old login wall.
+ return filepath.Join(home, ".freeos")
}
func absProductHome(p string) string {
diff --git a/desktop/src/settings_home_test.go b/desktop/src/settings_home_test.go
index 6700cb19..8fc8c7a6 100644
--- a/desktop/src/settings_home_test.go
+++ b/desktop/src/settings_home_test.go
@@ -14,6 +14,21 @@ func TestProductHomePrefersFreeosHome(t *testing.T) {
}
}
+func TestProductHomeIgnoresLegacyOctopDir(t *testing.T) {
+ root := t.TempDir()
+ t.Setenv("FREEOS_HOME", "")
+ t.Setenv("OCTOP_HOME", "")
+ t.Setenv("HOME", root)
+ t.Setenv("USERPROFILE", root)
+ if err := os.Mkdir(filepath.Join(root, ".octop"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ want := filepath.Join(root, ".freeos")
+ if got := productHome(); got != want {
+ t.Fatalf("productHome() = %q, want %q", got, want)
+ }
+}
+
func TestProductHomeHonorsOctopHome(t *testing.T) {
t.Setenv("FREEOS_HOME", "")
oct := filepath.Join(t.TempDir(), "oct")
diff --git a/desktop/src/settings_window.go b/desktop/src/settings_window.go
index a216eb62..ff05dab8 100644
--- a/desktop/src/settings_window.go
+++ b/desktop/src/settings_window.go
@@ -1,6 +1,11 @@
package main
-import "runtime"
+import (
+ "runtime"
+
+ "github.com/wailsapp/wails/v3/pkg/application"
+ "github.com/wailsapp/wails/v3/pkg/events"
+)
const (
settingsWindowWidth = 400
@@ -16,3 +21,28 @@ func settingsWindowOuterHeight() int {
}
return settingsWindowHeight
}
+
+func newSettingsWindow(app *application.App) *application.WebviewWindow {
+ settingsWin := app.Window.NewWithOptions(application.WebviewWindowOptions{
+ Title: "FreeOS 设置",
+ Width: settingsWindowWidth,
+ Height: settingsWindowOuterHeight(),
+ URL: "/?settings=1",
+ Hidden: true,
+ Frameless: true,
+ AlwaysOnTop: true,
+ DisableResize: true,
+ BackgroundColour: application.NewRGB(255, 255, 255),
+ Windows: application.WindowsWindow{
+ HiddenOnTaskbar: true,
+ },
+ })
+ settingsWin.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
+ e.Cancel()
+ settingsWin.Hide()
+ })
+ settingsWin.OnWindowEvent(events.Common.WindowLostFocus, func(_ *application.WindowEvent) {
+ settingsWin.Hide()
+ })
+ return settingsWin
+}
diff --git a/desktop/src/webview_nav.go b/desktop/src/webview_nav.go
new file mode 100644
index 00000000..ef0314f5
--- /dev/null
+++ b/desktop/src/webview_nav.go
@@ -0,0 +1,58 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "time"
+)
+
+type setURLFunc func(url string)
+
+func safeSetURL(set setURLFunc, url string) (err error) {
+ if set == nil {
+ return fmt.Errorf("webview window is not available")
+ }
+ defer func() {
+ if rec := recover(); rec != nil {
+ err = fmt.Errorf("webview navigate failed: %v", rec)
+ }
+ }()
+ set(url)
+ return nil
+}
+
+func navigateWhenReady(set setURLFunc, url string, attempts int) error {
+ if attempts < 1 {
+ attempts = 1
+ }
+ var last error
+ for i := 0; i < attempts; i++ {
+ last = safeSetURL(set, url)
+ if last == nil {
+ return nil
+ }
+ log.Printf("SetURL attempt %d/%d: %v", i+1, attempts, last)
+ time.Sleep(200 * time.Millisecond)
+ }
+ return last
+}
+
+func (a *App) markWebviewReady() {
+ a.webviewReadyOnce.Do(func() {
+ if a.webviewReady != nil {
+ close(a.webviewReady)
+ }
+ })
+}
+
+func (a *App) waitWebviewReady(timeout time.Duration) bool {
+ if a == nil || a.webviewReady == nil {
+ return true
+ }
+ select {
+ case <-a.webviewReady:
+ return true
+ case <-time.After(timeout):
+ return false
+ }
+}
diff --git a/desktop/src/webview_nav_test.go b/desktop/src/webview_nav_test.go
new file mode 100644
index 00000000..07db7362
--- /dev/null
+++ b/desktop/src/webview_nav_test.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func TestSafeSetURLRecoversNavigatePanic(t *testing.T) {
+ err := safeSetURL(func(string) {
+ panic("runtime error: invalid memory address or nil pointer dereference")
+ }, "http://127.0.0.1:8088/?desktop=1")
+ if err == nil {
+ t.Fatal("expected recovered panic")
+ }
+ if !strings.Contains(err.Error(), "webview navigate failed") {
+ t.Fatalf("error = %v", err)
+ }
+}
+
+func TestNavigateWhenReadyRetriesAfterChromiumPanic(t *testing.T) {
+ var calls atomic.Int32
+ var got string
+ url := "http://127.0.0.1:8088/?desktop=1"
+ err := navigateWhenReady(func(next string) {
+ n := calls.Add(1)
+ if n < 3 {
+ panic("runtime error: invalid memory address or nil pointer dereference")
+ }
+ got = next
+ }, url, 4)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != url {
+ t.Fatalf("url = %q", got)
+ }
+ if calls.Load() != 3 {
+ t.Fatalf("calls = %d, want 3", calls.Load())
+ }
+}
+
+func TestNavigateWhenReadySucceedsAfterReady(t *testing.T) {
+ var calls atomic.Int32
+ var got string
+ url := "http://127.0.0.1:8090/?desktop=1"
+ if err := navigateWhenReady(func(next string) {
+ calls.Add(1)
+ got = next
+ }, url, 1); err != nil {
+ t.Fatal(err)
+ }
+ if got != url || calls.Load() != 1 {
+ t.Fatalf("url=%q calls=%d", got, calls.Load())
+ }
+}
+
+func TestMarkWebviewReadyUnblocksWait(t *testing.T) {
+ api := &App{webviewReady: make(chan struct{})}
+ done := make(chan bool, 1)
+ go func() {
+ done <- api.waitWebviewReady(2 * time.Second)
+ }()
+ time.Sleep(20 * time.Millisecond)
+ api.markWebviewReady()
+ api.markWebviewReady()
+ if !<-done {
+ t.Fatal("waitWebviewReady should succeed after ignition")
+ }
+}
+
+func TestWaitWebviewReadyTimesOut(t *testing.T) {
+ api := &App{webviewReady: make(chan struct{})}
+ if api.waitWebviewReady(15 * time.Millisecond) {
+ t.Fatal("expected timeout before ignition")
+ }
+}
+
+func TestSafeSetURLNilWindow(t *testing.T) {
+ if err := safeSetURL(nil, "http://127.0.0.1/"); err == nil {
+ t.Fatal("expected error")
+ }
+}
diff --git a/src/octop/api/routers/health.py b/src/octop/api/routers/health.py
index de7e25bb..108c9f95 100644
--- a/src/octop/api/routers/health.py
+++ b/src/octop/api/routers/health.py
@@ -21,6 +21,7 @@ async def health(server: Any = Depends(get_server)) -> dict[str, Any]:
)
return {
"ok": True,
+ "product": "freeos",
"started_at": server._started_at,
"db": bound,
"users_loaded": users,
diff --git a/src/octop/infra/users/local_session.py b/src/octop/infra/users/local_session.py
index 20190ecb..e15c49c2 100644
--- a/src/octop/infra/users/local_session.py
+++ b/src/octop/infra/users/local_session.py
@@ -149,6 +149,7 @@ async def ensure_local_user(server: Any, *, locale: str) -> User:
picked.id,
)
return picked
+ return await _provision_local_user(server, locale=loc)
raise OctopError(ErrorCode.FORBIDDEN, "interactive login required")
diff --git a/src/octop/infra/utils/paths.py b/src/octop/infra/utils/paths.py
index bae368be..c6d9a979 100644
--- a/src/octop/infra/utils/paths.py
+++ b/src/octop/infra/utils/paths.py
@@ -20,7 +20,8 @@ def from_env(cls) -> PathLayout:
1. ``FREEOS_HOME``
2. ``OCTOP_HOME`` (legacy Octop identifier, still honored)
3. ``~/.freeos`` if it already exists
- 4. ``~/.octop`` if it already exists (existing Octop installs)
+ 4. ``~/.octop`` if it already exists (existing Octop server installs;
+ skipped when ``OCTOP_DESKTOP`` / ``FREEOS_DESKTOP`` is set)
5. ``~/.freeos`` for new installs
"""
for key in ("FREEOS_HOME", "OCTOP_HOME"):
@@ -32,7 +33,12 @@ def from_env(cls) -> PathLayout:
octop = home / ".octop"
if freeos.exists():
return cls(freeos)
- if octop.exists():
+ desktop = False
+ for key in ("OCTOP_DESKTOP", "FREEOS_DESKTOP"):
+ if (os.environ.get(key) or "").strip().lower() in {"1", "true", "yes", "on"}:
+ desktop = True
+ break
+ if octop.exists() and not desktop:
return cls(octop)
return cls(freeos)
diff --git a/tests/integration/test_auth_flow.py b/tests/integration/test_auth_flow.py
index 781f14df..c8edf00c 100644
--- a/tests/integration/test_auth_flow.py
+++ b/tests/integration/test_auth_flow.py
@@ -71,6 +71,7 @@ async def test_health_no_auth_required(client):
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
+ assert body.get("product") == "freeos"
assert isinstance(body.get("started_at"), int)
diff --git a/tests/unit/test_org_module.py b/tests/unit/test_org_module.py
index ffe22e6c..cfda1f97 100644
--- a/tests/unit/test_org_module.py
+++ b/tests/unit/test_org_module.py
@@ -56,11 +56,24 @@ def test_path_layout_keeps_existing_octop_home(
) -> None:
monkeypatch.delenv("FREEOS_HOME", raising=False)
monkeypatch.delenv("OCTOP_HOME", raising=False)
+ monkeypatch.delenv("OCTOP_DESKTOP", raising=False)
+ monkeypatch.delenv("FREEOS_DESKTOP", raising=False)
(tmp_path / ".octop").mkdir()
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
assert PathLayout.from_env().root == tmp_path / ".octop"
+def test_path_layout_desktop_skips_legacy_octop(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ monkeypatch.delenv("FREEOS_HOME", raising=False)
+ monkeypatch.delenv("OCTOP_HOME", raising=False)
+ monkeypatch.setenv("OCTOP_DESKTOP", "1")
+ (tmp_path / ".octop").mkdir()
+ monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
+ assert PathLayout.from_env().root == tmp_path / ".freeos"
+
+
def test_enable_from_desktop_env_first_run_only(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py
index b5504594..0d258bd8 100644
--- a/tests/unit/test_paths.py
+++ b/tests/unit/test_paths.py
@@ -85,3 +85,12 @@ def test_path_layout_from_env_defaults_to_dot_freeos(monkeypatch, tmp_path: Path
def test_path_layout_from_env_honors_octop_home(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("OCTOP_HOME", str(tmp_path))
assert PathLayout.from_env().root == tmp_path
+
+
+def test_path_layout_from_env_desktop_skips_legacy_octop(monkeypatch, tmp_path: Path) -> None:
+ monkeypatch.delenv("OCTOP_HOME", raising=False)
+ monkeypatch.delenv("FREEOS_HOME", raising=False)
+ monkeypatch.setenv("FREEOS_DESKTOP", "1")
+ (tmp_path / ".octop").mkdir()
+ monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
+ assert PathLayout.from_env().root == tmp_path / ".freeos"