From c7ae6b9471ab273e5b0c0ee700ae5c77ef69c330 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 27 Feb 2026 13:50:11 -0800 Subject: [PATCH] Favicons + custom port during dev --- .test-favicon.mjs | 54 +++++++++ README.md | 2 +- apps/desktop/scripts/dev-electron.mjs | 2 +- apps/server/package.json | 2 +- apps/server/src/wsServer.ts | 159 +++++++++++++++++++++++++- apps/web/src/components/Sidebar.tsx | 47 +++++++- apps/web/vite.config.ts | 2 +- scripts/dev-runner.mjs | 2 +- 8 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 .test-favicon.mjs diff --git a/.test-favicon.mjs b/.test-favicon.mjs new file mode 100644 index 0000000000000000000000000000000000000000..dc2c9a565ea7fb0daa40335dc8469e14564d1ac9 --- /dev/null +++ b/.test-favicon.mjs @@ -0,0 +1,54 @@ +import path from "path"; +import fs from "fs"; + +const FAVICON_CANDIDATES = [ + "favicon.svg", "favicon.ico", "favicon.png", + "public/favicon.svg", "public/favicon.ico", "public/favicon.png", + "app/favicon.ico", "app/favicon.png", "app/icon.svg", "app/icon.png", "app/icon.ico", + "src/favicon.ico", "src/favicon.svg", "src/app/favicon.ico", "src/app/icon.svg", "src/app/icon.png", +]; + +const ICON_SOURCE_FILES = [ + "index.html", "public/index.html", + "app/routes/__root.tsx", "src/routes/__root.tsx", + "app/root.tsx", "src/root.tsx", "src/index.html", +]; + +const LINK_ICON_HTML_RE = /]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i; +const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i; + +function test(projectCwd) { + console.log("--- Testing:", projectCwd, "---"); + + for (const c of FAVICON_CANDIDATES) { + const full = path.join(projectCwd, c); + if (fs.existsSync(full) && fs.statSync(full).isFile()) { + console.log(" Phase 1 FOUND:", c); + return; + } + } + console.log(" Phase 1: no match"); + + for (const sf of ICON_SOURCE_FILES) { + const full = path.join(projectCwd, sf); + if (!fs.existsSync(full)) continue; + const content = fs.readFileSync(full, "utf8"); + const href = content.match(LINK_ICON_HTML_RE)?.[1] || content.match(LINK_ICON_OBJ_RE)?.[1]; + if (href) { + const clean = href.replace(/^\//, ""); + const tryPaths = [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; + for (const p of tryPaths) { + if (fs.existsSync(p)) { + console.log(" Phase 2 FOUND:", sf, "->", href, "->", p); + return; + } + } + console.log(" Phase 2 href found but file missing:", href); + } + } + console.log(" Phase 2: no match"); +} + +test("/Users/theo/Code/Work/ct/t3chat"); +test("/Users/theo/Code/Work/lawn"); +test(process.cwd()); diff --git a/README.md b/README.md index 26f95f802cd2f835061c524f85b10644e928581f..9c231a3bf9d15dcf4c4b2add9582a97774b2f6c7 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ npx t3 Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together. -- Default ports: server `3773`, web `5173` +- Default ports: server `3773`, web `5733` - Shifted ports: `base + offset` (offset is hashed from `T3CODE_DEV_INSTANCE`) - Example: `T3CODE_DEV_INSTANCE=branch-a bun run dev:desktop` diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index c1dfad2a390ed326af541aacb757b6edbdf1d7c6..28970a2cc2030dc5bd3e007e52db98cf97ee2d85 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -5,7 +5,7 @@ import waitOn from "wait-on"; import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; -const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173); +const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); const devServerUrl = `http://localhost:${port}`; await waitOn({ diff --git a/apps/server/package.json b/apps/server/package.json index 20e804e5bfdc0ae4adc4c34b5e1312826d95e36d..f37ada2f2e05cadbe430f9c1925ff0d76b4e91ea 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -10,7 +10,7 @@ "type": "module", "main": "./dist/index.mjs", "scripts": { - "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5173} bun run src/index.ts", + "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5733} bun run src/index.ts", "build": "tsdown && node scripts/bundle-client.ts", "start": "node dist/index.mjs", "prepare": "effect-language-service patch", diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index f00a99019c16ae0173c88d3478e4fa7379776310..9918627e922c410cc90052634e7afc2b8f4f23de 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -230,8 +230,166 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); }); + // Well-known favicon paths checked in order. + const FAVICON_CANDIDATES = [ + "favicon.svg", + "favicon.ico", + "favicon.png", + "public/favicon.svg", + "public/favicon.ico", + "public/favicon.png", + "app/favicon.ico", + "app/favicon.png", + "app/icon.svg", + "app/icon.png", + "app/icon.ico", + "src/favicon.ico", + "src/favicon.svg", + "src/app/favicon.ico", + "src/app/icon.svg", + "src/app/icon.png", + ]; + + // Files that may contain a or icon metadata declaration. + const ICON_SOURCE_FILES = [ + "index.html", + "public/index.html", + "app/routes/__root.tsx", + "src/routes/__root.tsx", + "app/root.tsx", + "src/root.tsx", + "src/index.html", + ]; + + // Extract icon href from HTML/TSX source content. + // Matches or { rel: "icon", href: "…" } patterns. + const LINK_ICON_HTML_RE = /]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i; + const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i; + function extractIconHref(source: string): string | null { + const htmlMatch = source.match(LINK_ICON_HTML_RE); + if (htmlMatch?.[1]) return htmlMatch[1]; + const objMatch = source.match(LINK_ICON_OBJ_RE); + if (objMatch?.[1]) return objMatch[1]; + return null; + } + + /** + * Resolve an icon href (e.g. "/grass-logo.svg") to a file on disk. + * For href starting with "/" we look in public/ first, then project root. + */ + function resolveIconHref(projectCwd: string, href: string): string[] { + const clean = href.replace(/^\//, ""); + return [ + path.join(projectCwd, "public", clean), + path.join(projectCwd, clean), + ]; + } + + function serveFaviconFile( + filePath: string, + res: http.ServerResponse, + ): void { + const ext = path.extname(filePath); + const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; + fs.readFile(filePath, (readErr, data) => { + if (readErr) { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Read error"); + return; + } + res.writeHead(200, { + "Content-Type": contentType, + "Cache-Control": "public, max-age=3600", + }); + res.end(data); + }); + } + // HTTP server — serves static files or redirects to Vite dev server const httpServer = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", `http://localhost:${port}`); + + // Serve project favicons from their working directories. + // Handled before the dev redirect so it works in both modes. + if (url.pathname === "/api/project-favicon") { + const projectCwd = url.searchParams.get("cwd"); + if (!projectCwd) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("Missing cwd parameter"); + return; + } + + // Phase 1: Check well-known favicon paths. + const tryCandidates = (index: number) => { + if (index >= FAVICON_CANDIDATES.length) { + // Phase 2: Parse source files for icon link declarations. + trySourceFiles(0); + return; + } + const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); + if (!candidate.startsWith(projectCwd + path.sep)) { + tryCandidates(index + 1); + return; + } + fs.stat(candidate, (err, stats) => { + if (err || !stats?.isFile()) { + tryCandidates(index + 1); + return; + } + serveFaviconFile(candidate, res); + }); + }; + + // Phase 2: Read source/HTML files and extract icon href references. + const trySourceFiles = (index: number) => { + if (index >= ICON_SOURCE_FILES.length) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("No favicon found"); + return; + } + const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); + fs.readFile(sourceFile, "utf8", (err, content) => { + if (err) { + trySourceFiles(index + 1); + return; + } + const href = extractIconHref(content); + if (!href) { + trySourceFiles(index + 1); + return; + } + const candidates = resolveIconHref(projectCwd, href); + tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); + }); + }; + + const tryResolvedPaths = ( + paths: string[], + index: number, + onExhausted: () => void, + ) => { + if (index >= paths.length) { + onExhausted(); + return; + } + const candidate = paths[index]!; + if (!candidate.startsWith(projectCwd + path.sep)) { + tryResolvedPaths(paths, index + 1, onExhausted); + return; + } + fs.stat(candidate, (err, stats) => { + if (err || !stats?.isFile()) { + tryResolvedPaths(paths, index + 1, onExhausted); + return; + } + serveFaviconFile(candidate, res); + }); + }; + + tryCandidates(0); + return; + } + // In dev mode, redirect to Vite dev server if (devUrl) { res.writeHead(302, { Location: devUrl.href }); @@ -246,7 +404,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return; } - const url = new URL(req.url ?? "/", `http://localhost:${port}`); let filePath = path.join(staticDir, url.pathname); // SPA fallback: if no file extension and not found, serve index.html diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e3b6579c4a5d852122bab85efebd9470ed263c6f..35fd562650c99281956abe1d1b088727e2e7ede2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { ChevronRightIcon, TerminalIcon } from "lucide-react"; +import { ChevronRightIcon, FolderIcon, TerminalIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { DEFAULT_MODEL, @@ -141,6 +141,50 @@ function T3Wordmark() { ); } +/** + * Derives the server's HTTP origin (scheme + host + port) from the same + * sources WsTransport uses, converting ws(s) to http(s). + */ +function getServerHttpOrigin(): string { + const bridgeUrl = window.desktopBridge?.getWsUrl(); + const envUrl = import.meta.env.VITE_WS_URL as string | undefined; + const wsUrl = + bridgeUrl && bridgeUrl.length > 0 + ? bridgeUrl + : envUrl && envUrl.length > 0 + ? envUrl + : `ws://${window.location.hostname}:${window.location.port}`; + // Parse to extract just the origin, dropping path/query (e.g. ?token=…) + const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"); + try { + return new URL(httpUrl).origin; + } catch { + return httpUrl; + } +} + +const serverHttpOrigin = getServerHttpOrigin(); + +function ProjectFavicon({ cwd }: { cwd: string }) { + const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading"); + + const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`; + + if (status === "error") { + return ; + } + + return ( + setStatus("loaded")} + onError={() => setStatus("error")} + /> + ); +} + export default function Sidebar() { const { state, dispatch } = useStore(); const navigate = useNavigate(); @@ -573,6 +617,7 @@ export default function Sidebar() { project.expanded ? "rotate-90" : "" }`} /> + {project.name} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index eea4ea9476ebe6903a85a416224ca79bd3a68df9..652ebea30e13d01f0921d470707bd12f036cbfdd 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import { defineConfig } from "vite"; -const port = Number(process.env.PORT ?? 5173); +const port = Number(process.env.PORT ?? 5733); export default defineConfig({ plugins: [ diff --git a/scripts/dev-runner.mjs b/scripts/dev-runner.mjs index 512ff34a492b107af9742e35e19ac6d6254c36ba..5020bea97c2d82cc9d90d29cc9aac0b429fa69be 100644 --- a/scripts/dev-runner.mjs +++ b/scripts/dev-runner.mjs @@ -4,7 +4,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; const BASE_SERVER_PORT = 3773; -const BASE_WEB_PORT = 5173; +const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; export const DEFAULT_DEV_STATE_DIR = path.join(homedir(), ".t3", "dev"); const MODE_ARGS = { -- 2.54.0