diff --git a/client/index.html b/client/index.html index 45d93b3..ca1f0b6 100644 --- a/client/index.html +++ b/client/index.html @@ -4,7 +4,7 @@ - Claude Code + ccserver diff --git a/client/src/App.jsx b/client/src/App.jsx index ba42ed1..7e4da41 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -38,6 +38,20 @@ export default function App() { saveThemeId(themeId); }, [themeId]); + // Browser tab title: " ccserver" (hostname resolved server-side + // with the same precedence as the notify footer's _from: ). The + // static index.html fallback is "ccserver"; this upgrades it once the API + // answers. Silent on failure (e.g. token auth gate) -- the fallback stays. + // Idempotent, so React StrictMode's double mount is harmless. + useEffect(() => { + authFetch('/api/dirs/home') + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (data?.hostname) document.title = `${data.hostname} ccserver`; + }) + .catch(() => {}); + }, []); + const openTerminalTab = useCallback((dirPath, { claudeSessionId = null, shell = false, sessionId = null, attachSessionId = null, sandbox = false, sandboxOpts = null, app = 'claude', model = null, resume = false } = {}) => { const id = `terminal-${++tabIdCounter}`; const dirName = dirPath.split(/[/\\]/).filter(Boolean).pop() || dirPath; diff --git a/server/routes/dirs.js b/server/routes/dirs.js index 1a9962f..6b3f1e9 100644 --- a/server/routes/dirs.js +++ b/server/routes/dirs.js @@ -2,11 +2,15 @@ import { readdir, mkdir, stat } from 'node:fs/promises'; import { join, resolve, basename } from 'node:path'; import { homedir } from 'node:os'; import { loadSandboxConfig } from '../ws/sandbox.js'; +import { resolvedHostname } from '../ws/notify.js'; export async function dirsRoute(fastify, opts) { fastify.get('/dirs/home', async () => { const { defaultApp, forceSandbox } = loadSandboxConfig(); - return { home: homedir(), defaultApp, forceSandbox }; + // hostname for the browser tab title (" ccserver"): the same + // resolution the notify footer uses, so the tab matches _from: . + // Extra field, so existing clients are unaffected. + return { home: homedir(), defaultApp, forceSandbox, hostname: resolvedHostname() }; }); fastify.get('/dirs', async (request, reply) => { diff --git a/server/ws/notify.js b/server/ws/notify.js index 1e8bb14..5b79ab1 100644 --- a/server/ws/notify.js +++ b/server/ws/notify.js @@ -48,14 +48,21 @@ const LEVEL_EMOJI = { info: 'ℹ️', success: '✅', warning: '⚠️', error: let subscriptions = []; let notifyBroker = null; // { server, sockPath, dir, connections } | null +// Hostname for attribution and the browser tab title: CCSERVER_HOSTNAME wins +// over the config's notify.hostname, which in turn wins over the OS hostname +// (same priority pattern as CCSERVER_DISCORD_WEBHOOK, see sandbox.js). Exported +// so non-notify consumers (dirs.js /dirs/home -> client tab title) resolve the +// same name the notify footer shows (_from: ). +export function resolvedHostname() { + const notify = loadSandboxConfig().notify || {}; + return process.env.CCSERVER_HOSTNAME || notify.hostname || hostname(); +} + function loadNotifyConfig() { const notify = loadSandboxConfig().notify || { discordWebhook: null, subscriptions: [], hostname: null, attribution: true }; return { ...notify, - // Hostname for attribution: CCSERVER_HOSTNAME wins over the config's - // notify.hostname, which in turn wins over the OS hostname (same - // priority pattern as CCSERVER_DISCORD_WEBHOOK, see sandbox.js). - hostname: process.env.CCSERVER_HOSTNAME || notify.hostname || hostname(), + hostname: resolvedHostname(), attribution: notify.attribution !== false, }; } diff --git a/server/ws/notify.test.js b/server/ws/notify.test.js index 555df6d..8b66da9 100644 --- a/server/ws/notify.test.js +++ b/server/ws/notify.test.js @@ -17,6 +17,7 @@ import { listSubscriptions, restoreNotify, sendNotification, + resolvedHostname, } from './notify.js'; // Point CCSERVER_SANDBOX_CONFIG + CCSERVER_NOTIFY_PATH at temp files and @@ -309,3 +310,26 @@ test('notify hostname precedence: env wins over config, config over os.hostname( else process.env.CCSERVER_HOSTNAME = prevHost; } }); + +// resolvedHostname() (exported for the browser tab title, dirs.js /dirs/home): +// same precedence as the footer -- CCSERVER_HOSTNAME > notify.hostname > +// os.hostname(). +test('resolvedHostname precedence: env > notify.hostname > os.hostname()', async () => { + const prevHost = process.env.CCSERVER_HOSTNAME; + try { + process.env.CCSERVER_HOSTNAME = 'env-host'; + await withNotifyConfig({ notify: { hostname: 'cfg-host' } }, async () => { + assert.equal(resolvedHostname(), 'env-host'); + }); + delete process.env.CCSERVER_HOSTNAME; + await withNotifyConfig({ notify: { hostname: 'cfg-host' } }, async () => { + assert.equal(resolvedHostname(), 'cfg-host'); + }); + await withNotifyConfig({ notify: {} }, async () => { + assert.equal(resolvedHostname(), hostname()); + }); + } finally { + if (prevHost === undefined) delete process.env.CCSERVER_HOSTNAME; + else process.env.CCSERVER_HOSTNAME = prevHost; + } +}); diff --git a/tests/tab-title.spec.js b/tests/tab-title.spec.js new file mode 100644 index 0000000..4ae2b51 --- /dev/null +++ b/tests/tab-title.spec.js @@ -0,0 +1,13 @@ +import { test, expect } from '@playwright/test'; + +// The browser tab title is " ccserver" (hostname resolved +// server-side with the same precedence as the notify footer: CCSERVER_HOSTNAME +// > notify.hostname > os.hostname()), set by the client after fetching +// /api/dirs/home on mount. The static index.html fallback is "ccserver". No +// session launch needed -- the mount fetch is enough. Served from the +// production build (client/dist) by the e2e webServer, so this also covers the +// real delivery path. +test('tab title shows " ccserver"', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/^\S+ ccserver$/); +});