refactor(webchat): SwiftUI-only WebChat UI

# Conflicts:
#	apps/macos/Package.swift
This commit is contained in:
Peter Steinberger
2025-12-17 23:05:28 +01:00
parent ca85d217ec
commit 875cf9a054
7451 changed files with 218063 additions and 776607 deletions

View File

@@ -26,8 +26,7 @@ vi.mock("../gateway/call.js", () => ({
}));
vi.mock("../gateway/server.js", () => ({
startGatewayServer: (port: number, opts: unknown) =>
startGatewayServer(port, opts),
startGatewayServer: (port: number) => startGatewayServer(port),
}));
vi.mock("../globals.js", () => ({

View File

@@ -42,10 +42,6 @@ export function registerGatewayCli(program: Command) {
.command("gateway")
.description("Run the WebSocket Gateway")
.option("--port <port>", "Port for the gateway WebSocket", "18789")
.option(
"--webchat-port <port>",
"Port for the loopback WebChat HTTP server (default 18788)",
)
.option(
"--token <token>",
"Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
@@ -63,16 +59,6 @@ export function registerGatewayCli(program: Command) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
}
const webchatPort = opts.webchatPort
? Number.parseInt(String(opts.webchatPort), 10)
: undefined;
if (
webchatPort !== undefined &&
(Number.isNaN(webchatPort) || webchatPort <= 0)
) {
defaultRuntime.error("Invalid webchat port");
defaultRuntime.exit(1);
}
if (opts.force) {
try {
const killed = forceFreePort(port);
@@ -143,7 +129,7 @@ export function registerGatewayCli(program: Command) {
process.once("SIGINT", onSigint);
try {
server = await startGatewayServer(port, { webchatPort });
server = await startGatewayServer(port);
} catch (err) {
if (err instanceof GatewayLockError) {
defaultRuntime.error(`Gateway failed to start: ${err.message}`);

View File

@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const sendCommand = vi.fn();
const statusCommand = vi.fn();
const loginWeb = vi.fn();
const startWebChatServer = vi.fn(async () => ({ port: 18788 }));
const callGateway = vi.fn();
const runtime = {
@@ -25,10 +24,6 @@ vi.mock("../gateway/call.js", () => ({
callGateway,
randomIdempotencyKey: () => "idem-test",
}));
vi.mock("../webchat/server.js", () => ({
startWebChatServer,
getWebChatServer: () => null,
}));
vi.mock("./deps.js", () => ({
createDefaultDeps: () => ({}),
}));
@@ -54,16 +49,6 @@ describe("cli program", () => {
expect(statusCommand).toHaveBeenCalled();
});
it("starts webchat server and prints json", async () => {
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["webchat", "--json"], { from: "user" });
expect(startWebChatServer).toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith(
JSON.stringify({ port: 18788, basePath: "/", host: "127.0.0.1" }),
);
});
it("runs nodes list and calls node.pair.list", async () => {
callGateway.mockResolvedValue({ pending: [], paired: [] });
const program = buildProgram();

View File

@@ -26,7 +26,6 @@ import { danger, info, setVerbose } from "../globals.js";
import { loginWeb, logoutWeb } from "../provider-web.js";
import { defaultRuntime } from "../runtime.js";
import { VERSION } from "../version.js";
import { startWebChatServer } from "../webchat/server.js";
import { registerCronCli } from "./cron-cli.js";
import { createDefaultDeps } from "./deps.js";
import { registerDnsCli } from "./dns-cli.js";
@@ -362,43 +361,6 @@ Shows token usage per session when the agent reports it; set inbound.agent.conte
);
});
program
.command("webchat")
.description("Start or query the loopback-only web chat server")
.option("--port <port>", "Port to bind (default 18788)")
.option("--json", "Return JSON", false)
.action(async (opts) => {
const port = opts.port
? Number.parseInt(String(opts.port), 10)
: undefined;
const server = await startWebChatServer(port);
if (!server) {
const targetPort = port ?? 18788;
const msg = `webchat failed to start on http://127.0.0.1:${targetPort}/`;
if (opts.json) {
defaultRuntime.error(
JSON.stringify({ error: msg, port: targetPort }),
);
} else {
defaultRuntime.error(danger(msg));
}
defaultRuntime.exit(1);
return;
}
const payload = {
port: server.port,
basePath: "/",
host: "127.0.0.1",
};
if (opts.json) {
defaultRuntime.log(JSON.stringify(payload));
} else {
defaultRuntime.log(
info(`webchat listening on http://127.0.0.1:${server.port}/`),
);
}
});
const browser = program
.command("browser")
.description("Manage clawd's dedicated browser (Chrome/Chromium)")

View File

@@ -355,7 +355,7 @@ export async function agentCommand(
}
if (deliveryProvider === "webchat") {
const err = new Error(
"Delivering to WebChat is not supported via `clawdis agent`; use WebChat RPC instead.",
"Delivering to WebChat is not supported via `clawdis agent`; use WhatsApp/Telegram or run with --deliver=false.",
);
if (!bestEffortDeliver) throw err;
logDeliveryError(err);

View File

@@ -35,11 +35,6 @@ export type WebConfig = {
reconnect?: WebReconnectConfig;
};
export type WebChatConfig = {
enabled?: boolean;
port?: number;
};
export type BrowserConfig = {
enabled?: boolean;
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
@@ -141,7 +136,6 @@ export type ClawdisConfig = {
};
web?: WebConfig;
telegram?: TelegramConfig;
webchat?: WebChatConfig;
cron?: CronConfig;
bridge?: BridgeConfig;
discovery?: DiscoveryConfig;
@@ -266,12 +260,6 @@ const ClawdisSchema = z.object({
.optional(),
})
.optional(),
webchat: z
.object({
enabled: z.boolean().optional(),
port: z.number().int().positive().optional(),
})
.optional(),
telegram: z
.object({
botToken: z.string().optional(),

View File

@@ -85,7 +85,7 @@ export class GatewayClient {
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
client: {
name: this.opts.clientName ?? "webchat-backend",
name: this.opts.clientName ?? "gateway-client",
version: this.opts.clientVersion ?? "dev",
platform: this.opts.platform ?? process.platform,
mode: this.opts.mode ?? "backend",

View File

@@ -91,9 +91,6 @@ vi.mock("../commands/health.js", () => ({
vi.mock("../commands/status.js", () => ({
getStatusSummary: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock("../webchat/server.js", () => ({
ensureWebChatServerFromConfig: vi.fn().mockResolvedValue(null),
}));
vi.mock("../web/outbound.js", () => ({
sendMessageWhatsApp: vi
.fn()

View File

@@ -89,7 +89,6 @@ import { normalizeE164 } from "../utils.js";
import { setHeartbeatsEnabled } from "../web/auto-reply.js";
import { sendMessageWhatsApp } from "../web/outbound.js";
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
import { ensureWebChatServerFromConfig } from "../webchat/server.js";
import { buildMessageWithAttachments } from "./chat-attachments.js";
import {
type ConnectParams,
@@ -543,7 +542,6 @@ async function refreshHealthSnapshot(_opts?: { probe?: boolean }) {
export async function startGatewayServer(
port = 18789,
opts?: { webchatPort?: number },
): Promise<GatewayServer> {
const host = "127.0.0.1";
const httpServer: HttpServer = createHttpServer();
@@ -3419,19 +3417,6 @@ export async function startGatewayServer(
);
defaultRuntime.log(`gateway log file: ${getResolvedLoggerSettings().file}`);
// Start loopback WebChat server (unless disabled via config).
void ensureWebChatServerFromConfig(opts?.webchatPort)
.then((webchat) => {
if (webchat) {
defaultRuntime.log(
`webchat listening on http://127.0.0.1:${webchat.port}/`,
);
}
})
.catch((err) => {
logError(`gateway: webchat failed to start: ${String(err)}`);
});
// Start clawd browser control server (unless disabled via config).
void startBrowserControlServerFromConfig(defaultRuntime).catch((err) => {
logError(`gateway: clawd browser server failed to start: ${String(err)}`);

View File

@@ -7,8 +7,6 @@ import {
webAuthExists,
} from "../web/session.js";
const DEFAULT_WEBCHAT_PORT = 18788;
export async function buildProviderSummary(
cfg?: ClawdisConfig,
): Promise<string[]> {
@@ -35,13 +33,6 @@ export async function buildProviderSummary(
: chalk.cyan("Telegram: not configured"),
);
if (effective.webchat?.enabled === false) {
lines.push(chalk.yellow("WebChat: disabled"));
} else {
const port = effective.webchat?.port ?? DEFAULT_WEBCHAT_PORT;
lines.push(chalk.green(`WebChat: enabled (port ${port})`));
}
const allowFrom = effective.inbound?.allowFrom?.length
? effective.inbound.allowFrom.map(normalizeE164).filter(Boolean)
: [];

View File

@@ -1,62 +0,0 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { describe, expect, test } from "vitest";
import { startWebChatServer, stopWebChatServer } from "./server.js";
async function getFreePort(): Promise<number> {
const { createServer } = await import("node:net");
return await new Promise((resolve, reject) => {
const server = createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address() as AddressInfo;
const port = address.port as number;
server.close((err: Error | null) => (err ? reject(err) : resolve(port)));
});
});
}
const fetchText = (url: string) =>
new Promise<string>((resolve, reject) => {
http
.get(url, (res) => {
const chunks: Buffer[] = [];
res
.on("data", (c) =>
chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)),
)
.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")))
.on("error", reject);
})
.on("error", reject);
});
const fetchHeaders = (url: string) =>
new Promise<http.IncomingHttpHeaders>((resolve, reject) => {
http.get(url, (res) => resolve(res.headers)).on("error", reject);
});
describe("webchat server (static only)", () => {
test("serves index.html over loopback", { timeout: 8000 }, async () => {
const port = await getFreePort();
await startWebChatServer(port);
try {
const body = await fetchText(`http://127.0.0.1:${port}/`);
expect(body.toLowerCase()).toContain("<html");
} finally {
await stopWebChatServer();
}
});
test("serves bundled JS with module-friendly content type", async () => {
const port = await getFreePort();
await startWebChatServer(port);
try {
const headers = await fetchHeaders(
`http://127.0.0.1:${port}/webchat.bundle.js`,
);
expect(headers["content-type"]).toContain("application/javascript");
} finally {
await stopWebChatServer();
}
});
});

View File

@@ -1,173 +0,0 @@
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadConfig } from "../config/config.js";
import { logDebug, logError } from "../logger.js";
const WEBCHAT_DEFAULT_PORT = 18788;
type WebChatServerState = {
server: http.Server;
port: number;
};
let state: WebChatServerState | null = null;
function resolveWebRoot() {
const here = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
// Bundled inside Clawdis.app: .../Contents/Resources/WebChat
path.resolve(here, "../../../WebChat"),
// When running from repo without bundling
path.resolve(here, "../../WebChat"),
// Fallback to source tree location
path.resolve(here, "../../apps/macos/Sources/Clawdis/Resources/WebChat"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
throw new Error(`webchat assets not found; tried: ${candidates.join(", ")}`);
}
function notFound(res: http.ServerResponse) {
res.statusCode = 404;
res.end("Not Found");
}
function contentTypeForExt(ext: string) {
switch (ext) {
case ".html":
return "text/html";
case ".js":
return "application/javascript";
case ".css":
return "text/css";
case ".json":
case ".map":
return "application/json";
case ".svg":
return "image/svg+xml";
case ".png":
return "image/png";
case ".ico":
return "image/x-icon";
default:
return "application/octet-stream";
}
}
export async function startWebChatServer(
port = WEBCHAT_DEFAULT_PORT,
): Promise<WebChatServerState | null> {
if (state) return state;
const root = resolveWebRoot();
const server = http.createServer(async (req, res) => {
if (!req.url) return notFound(res);
if (
req.socket.remoteAddress &&
!req.socket.remoteAddress.startsWith("127.")
) {
res.statusCode = 403;
res.end("loopback only");
return;
}
const url = new URL(req.url, "http://127.0.0.1");
if (url.pathname === "/webchat" || url.pathname.startsWith("/webchat/")) {
let rel = url.pathname.replace(/^\/webchat\/?/, "");
if (!rel || rel.endsWith("/")) rel = `${rel}index.html`;
const filePath = path.join(root, rel);
if (!filePath.startsWith(root) || !fs.existsSync(filePath)) {
return notFound(res);
}
const data = fs.readFileSync(filePath);
const ext = path.extname(filePath).toLowerCase();
res.setHeader("Content-Type", contentTypeForExt(ext));
res.end(data);
return;
}
if (url.pathname === "/") {
const filePath = path.join(root, "index.html");
const data = fs.readFileSync(filePath);
res.setHeader("Content-Type", "text/html");
res.end(data);
return;
}
const relPath = url.pathname.replace(/^\//, "");
if (relPath) {
const filePath = path.join(root, relPath);
if (filePath.startsWith(root) && fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath);
const ext = path.extname(filePath).toLowerCase();
res.setHeader("Content-Type", contentTypeForExt(ext));
res.end(data);
return;
}
}
notFound(res);
});
try {
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => reject(err);
server.once("error", onError);
server.listen(port, "127.0.0.1", () => {
server.off("error", onError);
resolve();
});
});
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
const msg = code ? `${code}: ${String(err)}` : String(err);
logError(
`webchat server failed to bind 127.0.0.1:${port} (${msg}); continuing without webchat`,
);
return null;
}
state = { server, port };
return state;
}
export async function stopWebChatServer() {
if (!state) return;
if (state.server) {
await new Promise<void>((resolve) => state?.server.close(() => resolve()));
}
state = null;
}
// Legacy no-op: gateway readiness is now handled directly by clients.
export async function waitForWebChatGatewayReady() {
return;
}
export function __forceWebChatSnapshotForTests() {
// no-op: snapshots now come from the Gateway WS directly.
}
export async function __broadcastGatewayEventForTests() {
// no-op
}
export async function ensureWebChatServerFromConfig(overridePort?: number) {
const cfg = loadConfig();
if (cfg.webchat?.enabled === false) return null;
const port = overridePort ?? cfg.webchat?.port ?? WEBCHAT_DEFAULT_PORT;
try {
return await startWebChatServer(port);
} catch (err) {
logDebug(`webchat server failed to start: ${String(err)}`);
throw err;
}
}