mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
Merge pull request #103 from Ark0N/beta/session-detach
feat(web): session detach/undock + beta instance isolation (port 5000)
This commit is contained in:
+89
-11
@@ -36,10 +36,11 @@ import fastifyMultipart from '@fastify/multipart';
|
||||
import { startPasteImageGc } from './paste-image-gc.js';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { homedir, hostname as getHostname } from 'node:os';
|
||||
import { hostname as getHostname } from 'node:os';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, type BackgroundTask } from '../session.js';
|
||||
import type { ClaudeMode, SessionState } from '../types.js';
|
||||
@@ -146,7 +147,7 @@ import {
|
||||
* Certs are stored in ~/.codeman/certs/ and reused across restarts.
|
||||
*/
|
||||
function getOrCreateSelfSignedCert(): { key: string; cert: string } {
|
||||
const certsDir = join(homedir(), '.codeman', 'certs');
|
||||
const certsDir = dataPath('certs');
|
||||
const keyPath = join(certsDir, 'server.key');
|
||||
const certPath = join(certsDir, 'server.crt');
|
||||
|
||||
@@ -562,6 +563,17 @@ export class WebServer extends EventEmitter {
|
||||
this.app.get('/index.html', async (_req, reply) => {
|
||||
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml());
|
||||
});
|
||||
// Detached single-session window (undock). Serves the same SPA shell but
|
||||
// flags the client into "solo mode" for one session. Auth applies normally
|
||||
// (the popup carries the dashboard's cookie on navigation). We serve 200
|
||||
// even for an unknown id — the client renders a friendly "session
|
||||
// unavailable" state, which also covers a session that ends while its
|
||||
// detached window is still open. Registered before the static plugin so the
|
||||
// explicit route wins over the '/' static prefix.
|
||||
this.app.get('/session/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml(id));
|
||||
});
|
||||
// Service worker must never be cached — browsers check for SW updates on navigation
|
||||
this.app.get('/sw.js', async (_req, reply) => {
|
||||
return reply
|
||||
@@ -980,11 +992,77 @@ export class WebServer extends EventEmitter {
|
||||
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
||||
}
|
||||
|
||||
private renderIndexHtml(): string {
|
||||
return this.indexHtmlTemplate.replace(
|
||||
private renderIndexHtml(soloSessionId?: string): string {
|
||||
let html = this.indexHtmlTemplate.replace(
|
||||
'<title>Codeman</title>',
|
||||
`<title>${escapeHtmlText(this.windowTitle)}</title>`
|
||||
);
|
||||
// Cache-bust same-origin module scripts + stylesheets so a normal reload
|
||||
// always serves the latest (static assets carry a 1-year immutable cache).
|
||||
html = this.cacheBustAssets(html);
|
||||
// Detached single-session ("solo") window: inject the target session id so
|
||||
// the client can enter solo mode even if a (network-first) service worker
|
||||
// later serves a cached shell. The client primarily detects solo mode from
|
||||
// the /session/:id URL path; this global is a belt-and-suspenders fallback.
|
||||
// The id is gated to JSON + <-escaped so it can't break out of the inline
|
||||
// <script> (ids are UUIDs in practice, but defense-in-depth is cheap).
|
||||
if (soloSessionId) {
|
||||
const safeId = JSON.stringify(soloSessionId).replace(/</g, '\\u003c');
|
||||
html = html.replace('</head>', `<script>window.__CODEMAN_SOLO__=${safeId};</script>\n</head>`);
|
||||
}
|
||||
// Gesture-control overlay (Phase 5): dashboard only (not solo popups, which
|
||||
// have no tab strip), opt-in via CODEMAN_GESTURE=1. The bundle is served
|
||||
// same-origin from /gesture/ so 'self' covers it; CSP is widened to match in
|
||||
// registerSecurityHeaders under the same flag.
|
||||
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
|
||||
const v = this.gestureBundleVersion();
|
||||
html = html.replace('</head>', `<script type="module" src="/gesture/gesture-codeman.js${v}"></script>\n</head>`);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/** mtime memo for asset cache-busting (keyed by absolute path). A full index
|
||||
* render does one stat per script/link tag (~25-30); without this each `/`,
|
||||
* `/index.html` and `/session/:id` hit would re-stat them all. A 1s TTL keeps
|
||||
* a burst of renders cheap while still picking up an edited/redeployed file
|
||||
* within a second (no server restart needed). */
|
||||
private _assetVersionMemo = new Map<string, { v: number; ts: number }>();
|
||||
private assetVersion(absPath: string): number | null {
|
||||
const now = Date.now();
|
||||
const hit = this._assetVersionMemo.get(absPath);
|
||||
if (hit && now - hit.ts < 1000) return hit.v;
|
||||
try {
|
||||
const v = Math.floor(statSync(absPath).mtimeMs);
|
||||
this._assetVersionMemo.set(absPath, { v, ts: now });
|
||||
return v;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cache-busting query for the gesture bundle: its mtime (memoized, see
|
||||
* assetVersion). The bundle is served from /gesture/ with a 1-year cache, so
|
||||
* without a version that changes on redeploy the browser would keep running a
|
||||
* stale bundle forever. Empty string if the file is missing. */
|
||||
private gestureBundleVersion(): string {
|
||||
const v = this.assetVersion(join(__dirname, 'public', 'gesture', 'gesture-codeman.js'));
|
||||
return v === null ? '' : `?v=${v}`;
|
||||
}
|
||||
|
||||
/** Append ?v=<mtime> to every same-origin .js/.css reference in the page so a
|
||||
* normal reload always serves the latest. Codeman's static assets are sent
|
||||
* with `Cache-Control: max-age=1y, immutable` and the script/link tags carry
|
||||
* no version, so without this an edited module (panels-ui.js, styles.css, …)
|
||||
* stays cached until a manual hard refresh. mtime is memoized (1s TTL) so a
|
||||
* changed file is picked up with no server restart. External URLs (have a
|
||||
* `:` scheme), already-versioned refs (have a `?`), and refs with no matching
|
||||
* file on disk are left untouched. */
|
||||
private cacheBustAssets(html: string): string {
|
||||
const publicDir = join(__dirname, 'public');
|
||||
return html.replace(/(\s(?:src|href)=")([^"?:]+\.(?:js|css))(")/g, (full, pre, ref, post) => {
|
||||
const v = this.assetVersion(join(publicDir, ref));
|
||||
return v === null ? full : `${pre}${ref}?v=${v}${post}`;
|
||||
});
|
||||
}
|
||||
|
||||
private async setupSessionListeners(session: Session): Promise<void> {
|
||||
@@ -1088,7 +1166,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Helper to get custom CLAUDE.md template path from settings
|
||||
private async getDefaultClaudeMdPath(): Promise<string | undefined> {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
@@ -1112,7 +1190,7 @@ export class WebServer extends EventEmitter {
|
||||
if (this._settingsCache && now - this._settingsCache.ts < 2000) {
|
||||
return this._settingsCache.data;
|
||||
}
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const data = JSON.parse(content) as Record<string, unknown>;
|
||||
@@ -1619,7 +1697,7 @@ export class WebServer extends EventEmitter {
|
||||
// Tunnel only starts when user clicks the toggle in the UI — never on boot.
|
||||
// Reset persisted tunnelEnabled so the UI toggle reflects actual state.
|
||||
if (await this.isTunnelEnabled()) {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
@@ -1640,7 +1718,7 @@ export class WebServer extends EventEmitter {
|
||||
* Check if subagent tracking is enabled in settings (default: true)
|
||||
*/
|
||||
private async isSubagentTrackingEnabled(): Promise<boolean> {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
@@ -1658,7 +1736,7 @@ export class WebServer extends EventEmitter {
|
||||
* Check if image watcher is enabled in settings (default: false)
|
||||
*/
|
||||
private async isImageWatcherEnabled(): Promise<boolean> {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
@@ -1676,7 +1754,7 @@ export class WebServer extends EventEmitter {
|
||||
* Check if Cloudflare tunnel is enabled in settings (default: false)
|
||||
*/
|
||||
private async isTunnelEnabled(): Promise<boolean> {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
Reference in New Issue
Block a user