feat(desktop,server,web): app icons, completion notifications, single-user mode, bundled penguin CLI (#226)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-06 20:36:50 +08:00
committed by GitHub
parent 91f3292ab5
commit 082ab08e0b
34 changed files with 1404 additions and 100 deletions
+4 -1
View File
@@ -1,10 +1,12 @@
/**
* Admin user-backend routes: only the built-in admin can use these (403 for non-admins).
* Admin user-backend routes: only the built-in admin can use these (403 for non-admins),
* and desktop mode rejects the whole surface (single-user; 403 `desktop_single_user`).
* GET|POST /api/admin/users, POST /api/admin/users/:userId/password, DELETE /api/admin/users/:userId.
*/
import { Hono } from "hono";
import type { AdminUserCreateResponse, AdminUsersResponse } from "../../api/types.js";
import { HttpError } from "../errors.js";
import { rejectInDesktopMode } from "./desktop.js";
import type { AppEnv } from "../../auth/middleware.js";
import { pathParam, readJson, requireString } from "../validate.js";
import type { AppDeps } from "../../app.js";
@@ -12,6 +14,7 @@ import type { AppDeps } from "../../app.js";
export function adminUsersRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.use("*", rejectInDesktopMode(deps));
app.use("*", async (c, next) => {
if (!c.var.user.isAdmin) {
throw new HttpError(403, "admin_required", "Only an admin can perform this operation.");
+27 -5
View File
@@ -1,15 +1,37 @@
/**
* Desktop-mode routes: POST /api/desktop/shutdown.
* Desktop-mode routes: POST /api/desktop/shutdown, plus the shared desktop-mode guard
* that turns off multi-user surfaces (see rejectInDesktopMode).
*
* Authenticated by the shell's Bearer token, not the cookie session (the shell holds no
* cookie), so this mounts OUTSIDE authMiddleware and only when desktop mode is enabled.
* Responds 202 first, then triggers the graceful shutdown a beat later so the response
* isn't cut off by the closing listener.
* The shutdown route is authenticated by the shell's Bearer token, not the cookie
* session (the shell holds no cookie), so it mounts OUTSIDE authMiddleware and only
* when desktop mode is enabled. Responds 202 first, then triggers the graceful
* shutdown a beat later so the response isn't cut off by the closing listener.
*/
import { Hono } from "hono";
import type { MiddlewareHandler } from "hono";
import { HttpError } from "../errors.js";
import type { AppDeps } from "../../app.js";
/**
* Guard for user-management surfaces (admin users, Project members): the desktop app is
* single-user, so the whole surface answers 403 with a dedicated code rather than being
* unmounted — a stray client gets a clear, localizable error instead of a 404. Existing
* users and memberships in the data root are untouched; only the management routes are
* closed while the server runs under the desktop shell.
*/
export function rejectInDesktopMode(deps: AppDeps): MiddlewareHandler {
return async (_c, next) => {
if (deps.desktop !== null) {
throw new HttpError(
403,
"desktop_single_user",
"User management is disabled in the desktop app (single-user mode).",
);
}
await next();
};
}
/** Delay between answering 202 and starting shutdown: lets the response flush. */
const SHUTDOWN_DELAY_MS = 50;
@@ -2,16 +2,20 @@
* Member authorization routes:
* GET|POST /api/projects/:p/members, DELETE /api/projects/:p/members/:userId.
* Reading requires access; adding/removing is owner-only (validated inside the service).
* Desktop mode rejects the whole surface (single-user; 403 `desktop_single_user`).
*/
import { Hono } from "hono";
import type { MemberAddResponse, MembersResponse } from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import { rejectInDesktopMode } from "./desktop.js";
import { pathParam, readJson, requireString, requireValidId } from "../validate.js";
import type { AppDeps } from "../../app.js";
export function membersRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.use("*", rejectInDesktopMode(deps));
app.get("/", (c) => {
// Defensive id validation (FD-4).
const members = deps.projectService.listMembers(
+72 -2
View File
@@ -1,10 +1,11 @@
/**
* Desktop mode: one-shot desktop-login, Bearer-token shutdown, desktopMode in /api/me,
* and the desktop-session password change without oldPassword.
* the desktop-session password change without oldPassword, and the single-user guard
* closing the user-management and Project-member surfaces.
*/
import { describe, expect, it } from "vitest";
import { apiClient, createTestApp, loginAdmin } from "./helpers.js";
import type { MeResponse } from "../src/api/types.js";
import type { ErrorBody, MeResponse } from "../src/api/types.js";
const TOKEN = "test-desktop-token";
@@ -119,6 +120,75 @@ describe("desktop shutdown endpoint", () => {
});
});
describe("desktop single-user mode", () => {
async function expectSingleUser403(res: Response): Promise<void> {
expect(res.status).toBe(403);
const body = (await res.json()) as ErrorBody;
expect(body.error.code).toBe("desktop_single_user");
}
it("rejects the whole admin-users surface with desktop_single_user", async () => {
const t = await desktopApp();
try {
// The seeded admin signed in through the regular login form: even a fully
// authorized admin session gets the dedicated 403, not admin_required.
const admin = await loginAdmin(t.app);
const api = apiClient(t.app, admin.cookie);
await expectSingleUser403(await api.get("/api/admin/users"));
await expectSingleUser403(
await api.post("/api/admin/users", { userId: "eve", password: "password-123" }),
);
await expectSingleUser403(
await api.post("/api/admin/users/admin/password", { password: "password-456" }),
);
await expectSingleUser403(
await t.app.request("/api/admin/users/eve", {
method: "DELETE",
headers: { cookie: admin.cookie },
}),
);
// No user was created by the rejected POST.
expect(t.deps.db.prepare("SELECT COUNT(*) AS n FROM users").get()?.n).toBe(1);
} finally {
await t.cleanup();
}
});
it("rejects Project member management (reads and writes) with desktop_single_user", async () => {
const t = await desktopApp();
try {
const admin = await loginAdmin(t.app);
const api = apiClient(t.app, admin.cookie);
await expectSingleUser403(await api.get("/api/projects/default_project/members"));
await expectSingleUser403(
await api.post("/api/projects/default_project/members", { userId: "eve" }),
);
await expectSingleUser403(
await t.app.request("/api/projects/default_project/members/eve", {
method: "DELETE",
headers: { cookie: admin.cookie },
}),
);
} finally {
await t.cleanup();
}
});
it("leaves both surfaces working on a normal multi-user server", async () => {
const t = await createTestApp();
try {
const admin = await loginAdmin(t.app);
const api = apiClient(t.app, admin.cookie);
const users = await api.get("/api/admin/users");
expect(users.status).toBe(200);
const members = await api.get("/api/projects/default_project/members");
expect(members.status).toBe(200);
} finally {
await t.cleanup();
}
});
});
describe("desktop-session password change", () => {
async function desktopCookie(t: Awaited<ReturnType<typeof desktopApp>>): Promise<string> {
const res = await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`);