diff --git a/CHANGELOG.md b/CHANGELOG.md index 918978c0..1bd6f089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ ### 修复 +- Windows 打开时不再因 WebView2 `Navigate`/`SetURL` 在 Chromium 未就绪时触发 Go panic。主窗口等 WebView 点火后再跳转;`SetURL` 失败会重试并显示 FreeOS 提示,而不是原始堆栈。设置窗口延后创建,避免两个 WebView2 同时初始化同一用户目录。用户数据目录必须可写,并设置 `WEBVIEW2_USER_DATA_FOLDER`。 +- 已安装的 FreeOS 不再继承残留的 `~/.octop` 作为桌面家目录。无 `FREEOS_STAMP` 的 `~/.octop/portable` 会被丢弃;健康检查要求 `product=freeos`,避免连上旧 Octop 进程后出现红标登录墙。桌面端 `?desktop=1` 会记住本地会话并持续重试,不再落到登录页。 - Windows NSIS 安装完成后双击桌面 / 开始菜单快捷方式(或 `FreeOS.exe`)能打开窗口:快捷方式工作目录固定为 `$INSTDIR`,WebView2 用户数据写到 `%LOCALAPPDATA%\FreeOS\WebView2`(不再落到 Program Files),结束页用未提权 token 启动以免把 `~/.freeos` 标成 High integrity,并忽略非 `FreeOS.exe` 的残留 `desktop.pid`。 - Windows NSIS 结束页复选框不再显示 `è¿è¡Œ FreeOS`:`project.nsi` 使用 UTF-8 BOM,`makensis -INPUTCHARSET UTF8`,简体中文 LangString(运行 FreeOS / 卸载确认)按 Unicode 编译。 - Windows NSIS uninstall now stops FreeOS / host / sidecar processes and recursively removes `$INSTDIR` (quoted `RMDir /r`, plus a delayed cleanup after `uninstall.exe` exits). If those processes are still running, uninstall asks first (cancel aborts and leaves them running; confirm closes them then wipes the install dir). User profile data under `%USERPROFILE%\.freeos` / `FREEOS_HOME` / legacy `~/.octop` is kept. Product version remains 0.0.1. diff --git a/dashboard/src/components/AuthGuard.test.tsx b/dashboard/src/components/AuthGuard.test.tsx index f9fefa66..9b4a3397 100644 --- a/dashboard/src/components/AuthGuard.test.tsx +++ b/dashboard/src/components/AuthGuard.test.tsx @@ -86,4 +86,31 @@ describe("AuthGuard local session", () => { expect(await screen.findByText("login wall")).toBeInTheDocument(); expect(screen.queryByText("usable app")).toBeNull(); }); + + it("never opens the login wall inside the desktop shell", async () => { + getAuthStatus.mockResolvedValue({ setup_required: false }); + localSession.mockRejectedValue(new Error("interactive login required")); + + const view = render( + + + +
usable app
+ + } + /> + login wall} /> + setup wizard} /> +
+
, + ); + + await waitFor(() => expect(localSession).toHaveBeenCalled()); + expect(screen.queryByText("login wall")).toBeNull(); + expect(screen.queryByText("usable app")).toBeNull(); + view.unmount(); + }); }); diff --git a/dashboard/src/components/AuthGuard.tsx b/dashboard/src/components/AuthGuard.tsx index d5453672..40365907 100644 --- a/dashboard/src/components/AuthGuard.tsx +++ b/dashboard/src/components/AuthGuard.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { Spin } from "antd"; import { clearAuthToken, getAuthToken, setAuthToken } from "../api/request"; import { authApi, type OctopUser } from "../api/modules/auth"; import { applyUserLocale } from "../utils/locale"; +import { isDesktopShell } from "../utils/desktopShell"; import { CurrentUserProvider } from "../hooks/useCurrentUser"; import { AuthPromptProvider } from "../context/AuthPromptContext"; @@ -18,12 +19,15 @@ interface AuthGuardProps { */ export default function AuthGuard({ children }: AuthGuardProps) { const navigate = useNavigate(); + const [params] = useSearchParams(); + const desktopQuery = params.toString(); const [checking, setChecking] = useState(true); const [authed, setAuthed] = useState(false); const [user, setUser] = useState(null); useEffect(() => { let cancelled = false; + const desktop = isDesktopShell(desktopQuery ? `?${desktopQuery}` : ""); const adopt = async (me: OctopUser) => { await applyUserLocale(me.locale); @@ -34,30 +38,49 @@ export default function AuthGuard({ children }: AuthGuardProps) { } }; - const tryLocalSession = async (): Promise => { - for (let attempt = 0; attempt < 4; attempt += 1) { - try { - const res = await authApi.localSession(); - setAuthToken(res.access_token); - await adopt(res.user); - return true; - } catch { - if (attempt < 3) { - await new Promise((resolve) => { - window.setTimeout(resolve, 150); - }); - } + const adoptLocal = async (): Promise => { + try { + const res = await authApi.localSession(); + setAuthToken(res.access_token); + await adopt(res.user); + return true; + } catch { + return false; + } + }; + + const tryLocalSession = async (attempts: number, delayMs: number) => { + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (await adoptLocal()) return true; + if (attempt < attempts - 1) { + await new Promise((resolve) => { + window.setTimeout(resolve, delayMs); + }); } } return false; }; + const holdForDesktop = async () => { + while (!cancelled) { + if (await adoptLocal()) return; + await new Promise((resolve) => { + window.setTimeout(resolve, 400); + }); + } + }; + const check = async () => { try { const status = await authApi.getAuthStatus(); if (status.setup_required) { - if (await tryLocalSession()) return; + if (await tryLocalSession(desktop ? 20 : 4, desktop ? 250 : 150)) + return; + if (desktop) { + await holdForDesktop(); + return; + } clearAuthToken(); if (!cancelled) navigate("/setup", { replace: true }); return; @@ -65,7 +88,12 @@ export default function AuthGuard({ children }: AuthGuardProps) { const token = getAuthToken(); if (!token) { - if (await tryLocalSession()) return; + if (await tryLocalSession(desktop ? 20 : 4, desktop ? 250 : 150)) + return; + if (desktop) { + await holdForDesktop(); + return; + } if (!cancelled) { setAuthed(false); navigate("/login", { replace: true }); @@ -77,13 +105,22 @@ export default function AuthGuard({ children }: AuthGuardProps) { const me = await authApi.me(); await adopt(me); } catch { - if (await tryLocalSession()) return; + if (await tryLocalSession(desktop ? 20 : 4, desktop ? 250 : 150)) + return; + if (desktop) { + await holdForDesktop(); + return; + } if (!cancelled) { setAuthed(false); navigate("/login", { replace: true }); } } } catch { + if (desktop) { + await holdForDesktop(); + return; + } if (!cancelled) { setAuthed(true); setChecking(false); @@ -95,7 +132,7 @@ export default function AuthGuard({ children }: AuthGuardProps) { return () => { cancelled = true; }; - }, [navigate]); + }, [desktopQuery, navigate]); if (checking || !authed) { return ( diff --git a/dashboard/src/pages/Login/index.test.tsx b/dashboard/src/pages/Login/index.test.tsx index f41b3850..c963eb88 100644 --- a/dashboard/src/pages/Login/index.test.tsx +++ b/dashboard/src/pages/Login/index.test.tsx @@ -81,4 +81,24 @@ describe("LoginPage local session", () => { expect(await screen.findByText("login form")).toBeInTheDocument(); expect(screen.queryByText("usable app")).toBeNull(); }); + + it("does not render the login form inside the desktop shell", async () => { + localSession.mockRejectedValue(new Error("interactive login required")); + getAuthStatus.mockResolvedValue({ setup_required: false }); + getOidcStatus.mockResolvedValue({ enabled: false }); + + const view = render( + + + } /> + usable app} /> + setup wizard} /> + + , + ); + + await waitFor(() => expect(localSession).toHaveBeenCalled()); + expect(screen.queryByText("login form")).toBeNull(); + view.unmount(); + }); }); diff --git a/dashboard/src/pages/Login/index.tsx b/dashboard/src/pages/Login/index.tsx index 05659962..69e8ce00 100644 --- a/dashboard/src/pages/Login/index.tsx +++ b/dashboard/src/pages/Login/index.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; +import { Spin } from "antd"; import { message } from "@/utils/antdMessage"; import { useTranslation } from "react-i18next"; @@ -12,6 +13,7 @@ import { import AuthForm, { type AuthFormMode } from "../../components/AuthForm"; import BrandMark from "../../components/BrandMark"; import { applyGuestLocale, applyUserLocale } from "../../utils/locale"; +import { isDesktopShell } from "../../utils/desktopShell"; export default function LoginPage() { const { t } = useTranslation(); @@ -19,6 +21,7 @@ export default function LoginPage() { const [searchParams] = useSearchParams(); const [oidc, setOidc] = useState(null); const [mode, setMode] = useState("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"