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 @@ -13,12 +13,14 @@

### 变更

- 桌面端(`OCTOP_DESKTOP` / `FREEOS_DESKTOP`)首次打开不再经过验证启动密码、选择数据库、创建管理员三步:自动绑定本地 SQLite 并使用已有 guest / local-session。首屏只保留配置 LLM API Key,可「跳过,进入工作台」,之后仍可在设置里配模型。自托管服务端向导不变。
- 用户可见吉祥物从章鱼换成 XYAI 白色机器人姿势包(欢迎 / 空态 / 探头 / 思考 / 打字 / 任务);桌面启动页与 openXYOS 首页使用同一角色,不再加载章鱼 WebM。
- 桌面更新通道只跟踪 `github.com/XYAIStudio/FreeOS/releases`。不再读取 PyPI `octop`、腾讯云 COS 或其它 Octop 镜像;当前已是最新 FreeOS `v0.0.1` 时不会误报更新。
- 默认主题与残留 Octop `rose` 存储一次性迁移为 XYAI 蓝 `#0033FF`(之后仍可手动选玫瑰粉)。

### 修复

- 桌面 / 本地会话不再被 `setup_required` 短路:`GET /setup/status` 之后仍可调用 `POST /auth/local-session`,不会误跳进服务端密码向导。
- 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`。
Expand Down
8 changes: 8 additions & 0 deletions dashboard/src/api/modules/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export interface AuthStatus {
database_bound: boolean;
/** Active control-plane driver when bound (`sqlite` | `postgresql`). */
database_driver?: string | null;
/** True when the host process is the Wails desktop / portable shell. */
desktop?: boolean;
/** True when at least one LLM provider is already configured. */
has_providers?: boolean;
}

export interface OctopUser {
Expand Down Expand Up @@ -77,6 +81,8 @@ interface RawSetupStatus {
wizard_password_path?: string;
database_bound?: boolean;
database_driver?: string | null;
desktop?: boolean;
has_providers?: boolean;
}

interface RawLoginResponse {
Expand Down Expand Up @@ -121,6 +127,8 @@ export const authApi = {
wizard_password_path: raw.wizard_password_path,
database_bound: raw.database_bound ?? false,
database_driver: raw.database_driver ?? null,
desktop: raw.desktop ?? false,
has_providers: raw.has_providers ?? false,
};
return applySetupFlags(value);
} finally {
Expand Down
37 changes: 37 additions & 0 deletions dashboard/src/api/request.setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,43 @@ describe("setup lockdown handling", () => {
expect(replace).not.toHaveBeenCalled();
});

it("still allows /auth/local-session while lockdown is known", async () => {
mod.markSetupRequired();
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
access_token: "guest-token",
token_type: "Bearer",
expires_in: 3600,
user: { id: 1, username: "local", role: "admin" },
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);

const body = await mod.request<{ access_token: string }>(
"/auth/local-session",
{ method: "POST" },
);
expect(body.access_token).toBe("guest-token");
expect(fetchMock).toHaveBeenCalledOnce();
expect(replace).not.toHaveBeenCalled();
});

it("keeps desktop=1 when redirecting the Wails shell to setup", async () => {
Object.defineProperty(window, "location", {
configurable: true,
value: { pathname: "/chat", replace },
});
sessionStorage.setItem("freeos:desktop-shell", "1");

await expect(mod.request("/agents")).rejects.toThrow(
mod.SetupRequiredError,
);
expect(replace).toHaveBeenCalledWith("/setup?desktop=1");
sessionStorage.clear();
});

it("still allows /setup/status while lockdown is known", async () => {
mod.markSetupRequired();
fetchMock.mockResolvedValueOnce(
Expand Down
22 changes: 19 additions & 3 deletions dashboard/src/api/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,28 @@ function handleSetupRequired(): void {
}
_redirectingToSetup = true;
// Full reload drops any in-flight React state.
window.location.replace("/setup");
window.location.replace(setupRedirectPath());
}

/** Wizard endpoints stay reachable while lockdown is active. */
/** Remembered Wails shell flag — keep ``?desktop=1`` if the query was dropped. */
function setupRedirectPath(): string {
try {
if (window.sessionStorage.getItem("freeos:desktop-shell") === "1") {
return "/setup?desktop=1";
}
} catch {
/* private mode */
}
return "/setup";
}

/** Wizard + desktop guest session stay reachable while lockdown is active. */
function isSetupApiPath(path: string): boolean {
return path === "/setup/status" || path.startsWith("/setup/");
return (
path === "/setup/status" ||
path.startsWith("/setup/") ||
path === "/auth/local-session"
);
}

/**
Expand Down
45 changes: 45 additions & 0 deletions dashboard/src/components/AuthGuard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function renderGuard() {
describe("AuthGuard local session", () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
localSession.mockReset();
getAuthStatus.mockReset();
me.mockReset();
Expand Down Expand Up @@ -87,6 +88,50 @@ describe("AuthGuard local session", () => {
expect(screen.queryByText("usable app")).toBeNull();
});

it("sends the desktop first launch to model setup, not the login wall", async () => {
getAuthStatus.mockResolvedValue({
setup_required: true,
has_providers: false,
desktop: true,
});
localSession.mockResolvedValue({
access_token: "guest-token",
token_type: "Bearer",
expires_in: 3600,
user: {
id: 1,
username: "local",
role: "admin",
display_name: "FreeOS",
locale: "zh",
is_local: true,
},
token: "guest-token",
});

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>model setup</div>} />
</Routes>
</MemoryRouter>,
);

expect(await screen.findByText("model setup")).toBeInTheDocument();
expect(screen.queryByText("login wall")).toBeNull();
expect(screen.queryByText("usable app")).toBeNull();
expect(getAuthToken()).toBe("guest-token");
});

it("never opens the login wall inside the desktop shell", async () => {
getAuthStatus.mockResolvedValue({ setup_required: false });
localSession.mockRejectedValue(new Error("interactive login required"));
Expand Down
53 changes: 44 additions & 9 deletions dashboard/src/components/AuthGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 { desktopPostSessionPath } from "../utils/desktopOnboarding";
import { isDesktopShell } from "../utils/desktopShell";
import { CurrentUserProvider } from "../hooks/useCurrentUser";
import { AuthPromptProvider } from "../context/AuthPromptContext";
Expand Down Expand Up @@ -38,20 +39,32 @@ export default function AuthGuard({ children }: AuthGuardProps) {
}
};

const adoptLocal = async (): Promise<boolean> => {
const enterAfterSession = async (me: OctopUser, hasProviders: boolean) => {
if (desktop && desktopPostSessionPath(hasProviders) === "/setup") {
if (!cancelled) navigate("/setup", { replace: true });
return;
}
await adopt(me);
};

const adoptLocal = async (hasProviders = false): Promise<boolean> => {
try {
const res = await authApi.localSession();
setAuthToken(res.access_token);
await adopt(res.user);
await enterAfterSession(res.user, hasProviders);
return true;
} catch {
return false;
}
};

const tryLocalSession = async (attempts: number, delayMs: number) => {
const tryLocalSession = async (
attempts: number,
delayMs: number,
hasProviders = false,
) => {
for (let attempt = 0; attempt < attempts; attempt += 1) {
if (await adoptLocal()) return true;
if (await adoptLocal(hasProviders)) return true;
if (attempt < attempts - 1) {
await new Promise((resolve) => {
window.setTimeout(resolve, delayMs);
Expand All @@ -61,9 +74,9 @@ export default function AuthGuard({ children }: AuthGuardProps) {
return false;
};

const holdForDesktop = async () => {
const holdForDesktop = async (hasProviders = false) => {
while (!cancelled) {
if (await adoptLocal()) return;
if (await adoptLocal(hasProviders)) return;
await new Promise((resolve) => {
window.setTimeout(resolve, 400);
});
Expand All @@ -75,20 +88,42 @@ export default function AuthGuard({ children }: AuthGuardProps) {
const status = await authApi.getAuthStatus();

if (status.setup_required) {
if (await tryLocalSession(desktop ? 20 : 4, desktop ? 250 : 150))
if (
await tryLocalSession(
desktop ? 20 : 4,
desktop ? 250 : 150,
status.has_providers === true,
)
)
return;
if (desktop) {
await holdForDesktop();
await holdForDesktop(status.has_providers === true);
return;
}
clearAuthToken();
if (!cancelled) navigate("/setup", { replace: true });
return;
}

if (
desktop &&
desktopPostSessionPath(status.has_providers === true) === "/setup"
) {
if (await tryLocalSession(20, 250, status.has_providers === true))
return;
if (!cancelled) navigate("/setup", { replace: true });
return;
}

const token = getAuthToken();
if (!token) {
if (await tryLocalSession(desktop ? 20 : 4, desktop ? 250 : 150))
if (
await tryLocalSession(
desktop ? 20 : 4,
desktop ? 250 : 150,
status.has_providers === true,
)
)
return;
if (desktop) {
await holdForDesktop();
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4238,6 +4238,8 @@
},
"wizard": {
"title": "Initial setup",
"desktopTitle": "Configure a model",
"checking": "Checking setup status…",
"back": "Back",
"sessionExpired": "Wizard session expired. Please verify the bootstrap password again.",
"stepDatabase": "Choose database",
Expand Down Expand Up @@ -4309,6 +4311,7 @@
"model": {
"intro": "Configure one model provider so the assistant can reply. You can skip this step and configure it later.",
"skip": "Skip",
"skipToWorkspace": "Skip and enter workspace",
"presetTab": "Preset provider",
"customTab": "Custom provider",
"showMorePresets": "Show more providers ({{count}})",
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -4376,6 +4376,8 @@
},
"wizard": {
"title": "初始化设置",
"desktopTitle": "配置模型",
"checking": "正在检查初始化状态…",
"back": "上一步",
"sessionExpired": "向导会话已过期,请重新验证启动密码。",
"stepDatabase": "选择数据库",
Expand Down Expand Up @@ -4448,6 +4450,7 @@
"model": {
"intro": "配置一个模型提供商,助手就可以回应你了。你也可以跳过本步,稍后在「模型」页配置。",
"skip": "跳过",
"skipToWorkspace": "跳过,进入工作台",
"presetTab": "预置提供商",
"customTab": "自定义提供商",
"showMorePresets": "显示更多提供商({{count}} 个)",
Expand Down
32 changes: 32 additions & 0 deletions dashboard/src/pages/Login/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function renderLogin() {
describe("LoginPage local session", () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
localSession.mockReset();
getAuthStatus.mockReset();
getOidcStatus.mockReset();
Expand Down Expand Up @@ -82,6 +83,37 @@ describe("LoginPage local session", () => {
expect(screen.queryByText("usable app")).toBeNull();
});

it("opens desktop first launch on model setup", async () => {
localSession.mockResolvedValue({
access_token: "guest-token",
token_type: "Bearer",
expires_in: 3600,
user: {
id: 1,
username: "local",
role: "admin",
display_name: "FreeOS",
locale: "zh",
is_local: true,
},
token: "guest-token",
});

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>model setup</div>} />
</Routes>
</MemoryRouter>,
);

expect(await screen.findByText("model setup")).toBeInTheDocument();
expect(screen.queryByText("login form")).toBeNull();
expect(getAuthToken()).toBe("guest-token");
});

it("does not render the login form inside the desktop shell", async () => {
localSession.mockRejectedValue(new Error("interactive login required"));
getAuthStatus.mockResolvedValue({ setup_required: false });
Expand Down
11 changes: 9 additions & 2 deletions dashboard/src/pages/Login/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import AuthForm, { type AuthFormMode } from "../../components/AuthForm";
import BrandMark from "../../components/BrandMark";
import { applyGuestLocale, applyUserLocale } from "../../utils/locale";
import { desktopPostSessionPath } from "../../utils/desktopOnboarding";
import { isDesktopShell } from "../../utils/desktopShell";

export default function LoginPage() {
Expand All @@ -37,7 +38,11 @@ export default function LoginPage() {
const session = await authApi.localSession();
setAuthToken(session.access_token);
await applyUserLocale(session.user.locale);
if (!cancelled) navigate("/chat", { replace: true });
if (!cancelled) {
navigate(desktop ? desktopPostSessionPath() : "/chat", {
replace: true,
});
}
return;
} catch {
if (attempt < attempts - 1) {
Expand All @@ -53,7 +58,9 @@ export default function LoginPage() {
const session = await authApi.localSession();
setAuthToken(session.access_token);
await applyUserLocale(session.user.locale);
if (!cancelled) navigate("/chat", { replace: true });
if (!cancelled) {
navigate(desktopPostSessionPath(), { replace: true });
}
return;
} catch {
await new Promise((resolve) => {
Expand Down
Loading
Loading