Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions dashboard/src/components/AuthGuard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MemoryRouter initialEntries={["/chat?desktop=1"]}>
<Routes>
<Route
path="/chat"
element={
<AuthGuard>
<div>usable app</div>
</AuthGuard>
}
/>
<Route path="/login" element={<div>login wall</div>} />
<Route path="/setup" element={<div>setup wizard</div>} />
</Routes>
</MemoryRouter>,
);

await waitFor(() => expect(localSession).toHaveBeenCalled());
expect(screen.queryByText("login wall")).toBeNull();
expect(screen.queryByText("usable app")).toBeNull();
view.unmount();
});
});
73 changes: 55 additions & 18 deletions dashboard/src/components/AuthGuard.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<OctopUser | null>(null);

useEffect(() => {
let cancelled = false;
const desktop = isDesktopShell(desktopQuery ? `?${desktopQuery}` : "");

const adopt = async (me: OctopUser) => {
await applyUserLocale(me.locale);
Expand All @@ -34,38 +38,62 @@ export default function AuthGuard({ children }: AuthGuardProps) {
}
};

const tryLocalSession = async (): Promise<boolean> => {
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<boolean> => {
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;
}

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 });
Expand All @@ -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);
Expand All @@ -95,7 +132,7 @@ export default function AuthGuard({ children }: AuthGuardProps) {
return () => {
cancelled = true;
};
}, [navigate]);
}, [desktopQuery, navigate]);

if (checking || !authed) {
return (
Expand Down
20 changes: 20 additions & 0 deletions dashboard/src/pages/Login/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MemoryRouter initialEntries={["/login?desktop=1"]}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/chat" element={<div>usable app</div>} />
<Route path="/setup" element={<div>setup wizard</div>} />
</Routes>
</MemoryRouter>,
);

await waitFor(() => expect(localSession).toHaveBeenCalled());
expect(screen.queryByText("login form")).toBeNull();
view.unmount();
});
});
45 changes: 41 additions & 4 deletions dashboard/src/pages/Login/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -12,13 +13,15 @@ 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();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [oidc, setOidc] = useState<OidcStatus | null>(null);
const [mode, setMode] = useState<AuthFormMode>("login");
const desktop = isDesktopShell(`?${searchParams.toString()}`);

useEffect(() => {
void applyGuestLocale();
Expand All @@ -27,21 +30,39 @@ 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);
await applyUserLocale(session.user.locale);
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;
Expand All @@ -60,7 +81,7 @@ export default function LoginPage() {
return () => {
cancelled = true;
};
}, [navigate]);
}, [desktop, navigate]);

useEffect(() => {
const code = searchParams.get("oidc_error");
Expand All @@ -77,6 +98,22 @@ export default function LoginPage() {
navigate("/chat", { replace: true });
};

if (desktop) {
return (
<div
style={{
height: "100dvh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--fn-bg-layout)",
}}
>
<Spin size="large" />
</div>
);
}

return (
<div
style={{
Expand Down
18 changes: 18 additions & 0 deletions dashboard/src/utils/desktopShell.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { afterEach, describe, expect, it } from "vitest";
import { isDesktopShell } from "./desktopShell";

describe("isDesktopShell", () => {
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);
});
});
25 changes: 25 additions & 0 deletions dashboard/src/utils/desktopShell.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
6 changes: 6 additions & 0 deletions desktop/src/desktop_copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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…",
Expand All @@ -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。请退出旧进程或重新安装后再试。",
},
}

Expand Down
Loading
Loading