Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
<meta name="description" content="Parallel agentic development with Electron + React" />
<meta name="theme-color" content="#1e1e1e" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" vite-ignore />
<link rel="icon" href="/favicon.ico" data-theme-icon vite-ignore />
<link rel="apple-touch-icon" href="/icon.png" vite-ignore />
<!-- Keep favicon paths relative so browser mode still resolves icons behind URL prefixes. -->
<link rel="icon" href="favicon.png" type="image/png" data-theme-icon />
<link rel="apple-touch-icon" href="icon.png" />
<title>mux - coder multiplexer</title>
<style>
body {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@
"dist/**/*.woff2",
"dist/**/*.json",
"dist/**/*.png",
"dist/**/*.ico",
"dist/**/*.webm",
"dist/assets/**/*",
"scripts/postinstall.sh",
Expand Down
Binary file added public/favicon-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 13 additions & 1 deletion scripts/generate-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const PNG_OUTPUT = path.join(BUILD_DIR, "icon.png");
const ICNS_OUTPUT = path.join(BUILD_DIR, "icon.icns");
const FAVICON_OUTPUT = path.join(ROOT, "public", "favicon.ico");
const FAVICON_DARK_OUTPUT = path.join(ROOT, "public", "favicon-dark.ico");
const FAVICON_PNG_OUTPUT = path.join(ROOT, "public", "favicon.png");
const FAVICON_DARK_PNG_OUTPUT = path.join(ROOT, "public", "favicon-dark.png");

const THEME_FAVICON_STYLE = `<style>
:root {
Expand Down Expand Up @@ -209,6 +211,11 @@ async function generateFavicon(source: string, output: string) {
console.warn(" ⚠ ImageMagick not found, favicon.ico is single-resolution");
}

async function generateFaviconPng(source: string, output: string) {
const pngBuffer = await sharp(source).resize(256, 256).png().toBuffer();
await writeFile(output, pngBuffer);
}

async function generateThemeFaviconSvg(output: string) {
const svg = await readFile(SOURCE_BLACK, "utf8");
const withCurrentColor = svg.replace(/fill="(black|white)"/g, 'fill="currentColor"');
Expand Down Expand Up @@ -239,11 +246,16 @@ async function updateAllLogos() {
await generateThemeFaviconSvg(docsFaviconPath);
console.log("✓ docs/favicon.svg");

// Generate favicons (light/dark)
// Generate favicons (light/dark) in both ICO and PNG formats.
// Browser tabs use PNG to avoid MIME mismatches when ImageMagick is unavailable.
await generateFavicon(MONO_ICON.source, FAVICON_OUTPUT);
console.log(`✓ public/favicon.ico`);
await generateFavicon(SOURCE_WHITE, FAVICON_DARK_OUTPUT);
console.log(`✓ public/favicon-dark.ico`);
await generateFaviconPng(MONO_ICON.source, FAVICON_PNG_OUTPUT);
console.log(`✓ public/favicon.png`);
await generateFaviconPng(SOURCE_WHITE, FAVICON_DARK_PNG_OUTPUT);
console.log(`✓ public/favicon-dark.png`);

console.log("\n✅ All logos updated successfully!");
}
Expand Down
20 changes: 20 additions & 0 deletions src/browser/contexts/ThemeContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,24 @@ describe("ThemeContext", () => {
// Check that localStorage is still light (since forcedTheme doesn't write to storage by itself)
expect(JSON.parse(window.localStorage.getItem(UI_THEME_KEY)!)).toBe("light");
});

test("updates the browser tab favicon when the theme changes", () => {
const favicon = document.createElement("link");
favicon.rel = "icon";
favicon.dataset.themeIcon = "true";
favicon.href = "favicon.png";
document.head.appendChild(favicon);

try {
render(
<ThemeProvider forcedTheme="dark">
<TestComponent />
</ThemeProvider>
);

expect(favicon.href).toContain("favicon-dark.png");
} finally {
favicon.remove();
}
});
});
22 changes: 17 additions & 5 deletions src/browser/contexts/ThemeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ const THEME_COLORS: Record<ThemeMode, string> = {
};

const FAVICON_BY_SCHEME: Record<"light" | "dark", string> = {
light: "/favicon.ico",
dark: "/favicon-dark.ico",
light: "favicon.png",
dark: "favicon-dark.png",
};

/** Map theme mode to CSS color-scheme value */
Expand All @@ -79,9 +79,21 @@ function applyThemeFavicon(theme: ThemeMode) {
}

const scheme = getColorScheme(theme);
const nextHref = FAVICON_BY_SCHEME[scheme];
if (favicon.getAttribute("href") !== nextHref) {
favicon.setAttribute("href", nextHref);
// Use the document base URL so browser mode still resolves favicons behind URL
// prefixes, while falling back safely in minimal test DOMs without baseURI.
const baseHref =
document.baseURI || (typeof window !== "undefined" ? window.location.href : undefined);
let nextHref = FAVICON_BY_SCHEME[scheme];
if (baseHref) {
try {
nextHref = new URL(nextHref, baseHref).toString();
} catch {
// Keep the relative fallback when URL parsing is unavailable.
}
}

if (favicon.href !== nextHref) {
favicon.href = nextHref;
}
}

Expand Down
96 changes: 95 additions & 1 deletion src/node/orpc/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import * as fs from "fs/promises";
import * as os from "os";
import * as path from "path";
import { createOrpcServer } from "./server";
import { createOrpcServer, resolveOrpcStaticDir } from "./server";
import type { ORPCContext } from "./context";

function getErrorCode(error: unknown): string | null {
Expand All @@ -18,6 +18,39 @@ function getErrorCode(error: unknown): string | null {
return typeof code === "string" ? code : null;
}

describe("resolveOrpcStaticDir", () => {
test("prefers the dist layout when dist/index.html exists", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-static-resolve-"));

try {
const baseDir = path.join(tempDir, "dist", "node", "orpc");
await fs.mkdir(baseDir, { recursive: true });
await fs.writeFile(path.join(tempDir, "dist", "index.html"), "dist-index", "utf-8");
await fs.writeFile(path.join(tempDir, "index.html"), "root-index", "utf-8");

const resolved = await resolveOrpcStaticDir(baseDir);
expect(resolved).toBe(path.join(tempDir, "dist"));
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});

test("falls back to the repo-root layout when dist/index.html is missing", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-static-resolve-"));

try {
const baseDir = path.join(tempDir, "src", "node", "orpc");
await fs.mkdir(baseDir, { recursive: true });
await fs.writeFile(path.join(tempDir, "index.html"), "root-index", "utf-8");

const resolved = await resolveOrpcStaticDir(baseDir);
expect(resolved).toBe(tempDir);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});

describe("createOrpcServer", () => {
test("serveStatic fallback does not swallow /api routes", async () => {
// Minimal context stub - router won't be exercised by this test.
Expand Down Expand Up @@ -55,6 +88,67 @@ describe("createOrpcServer", () => {
}
});

test("serveStatic does not rewrite missing asset requests to index.html", async () => {
const stubContext: Partial<ORPCContext> = {};

const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-static-assets-"));
const indexHtml =
"<!doctype html><html><head><title>mux</title></head><body><div>ok</div></body></html>";

let server: Awaited<ReturnType<typeof createOrpcServer>> | null = null;

try {
await fs.writeFile(path.join(tempDir, "index.html"), indexHtml, "utf-8");

server = await createOrpcServer({
host: "127.0.0.1",
port: 0,
context: stubContext as ORPCContext,
authToken: "test-token",
serveStatic: true,
staticDir: tempDir,
});

const assetRes = await fetch(`${server.baseUrl}/favicon-missing.png`);
expect(assetRes.status).toBe(404);
} finally {
await server?.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});

test("serveStatic serves root-level assets from staticDir/public", async () => {
const stubContext: Partial<ORPCContext> = {};

const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-static-public-"));
const indexHtml =
"<!doctype html><html><head><title>mux</title></head><body><div>ok</div></body></html>";

let server: Awaited<ReturnType<typeof createOrpcServer>> | null = null;

try {
await fs.writeFile(path.join(tempDir, "index.html"), indexHtml, "utf-8");
await fs.mkdir(path.join(tempDir, "public"), { recursive: true });
await fs.writeFile(path.join(tempDir, "public", "favicon.png"), "icon-bytes", "utf-8");

server = await createOrpcServer({
host: "127.0.0.1",
port: 0,
context: stubContext as ORPCContext,
authToken: "test-token",
serveStatic: true,
staticDir: tempDir,
});

const assetRes = await fetch(`${server.baseUrl}/favicon.png`);
expect(assetRes.status).toBe(200);
expect(await assetRes.text()).toBe("icon-bytes");
} finally {
await server?.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});

test("OAuth callback routes accept POST redirects (query + form_post)", async () => {
const stubContext: Partial<ORPCContext> = {
muxGovernorOauthService: {
Expand Down
46 changes: 41 additions & 5 deletions src/node/orpc/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface OrpcServerOptions {
context: ORPCContext;
/** Whether to serve static files and SPA fallback (default: false) */
serveStatic?: boolean;
/** Directory to serve static files from (default: dist/ relative to dist/node/orpc/) */
/** Directory to serve static files from (default: auto-detect dist/ or repo root). */
staticDir?: string;
/** Custom error handler for oRPC errors */
onOrpcError?: (error: unknown, options: unknown) => void;
Expand Down Expand Up @@ -118,6 +118,28 @@ function escapeHtml(input: string): string {
.replaceAll("'", "&#39;");
}

async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}

export async function resolveOrpcStaticDir(baseDir: string = __dirname): Promise<string> {
const candidates = [path.join(baseDir, "../.."), path.join(baseDir, "../../..")];

for (const candidate of candidates) {
const indexPath = path.join(candidate, "index.html");
if (await pathExists(indexPath)) {
return candidate;
}
}

return candidates[0];
}

/**
* Create an oRPC server with HTTP and WebSocket endpoints.
*
Expand All @@ -132,8 +154,7 @@ export async function createOrpcServer({
authToken,
context,
serveStatic = false,
// From dist/node/orpc/, go up 2 levels to reach dist/ where index.html lives
staticDir = path.join(__dirname, "../.."),
staticDir,
onOrpcError = (error, options) => {
// Auth failures are expected in browser mode while the user enters the token.
// Avoid spamming error logs with stack traces on every unauthenticated request.
Expand All @@ -160,19 +181,28 @@ export async function createOrpcServer({
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ extended: false }));

const resolvedStaticDir = staticDir ?? (await resolveOrpcStaticDir());

let spaIndexHtml: string | null = null;

// Static file serving (optional)
if (serveStatic) {
try {
const indexHtmlPath = path.join(staticDir, "index.html");
const indexHtmlPath = path.join(resolvedStaticDir, "index.html");
const indexHtml = await fs.readFile(indexHtmlPath, "utf8");
spaIndexHtml = injectBaseHref(indexHtml, "/");
} catch (error) {
log.error("Failed to read index.html for SPA fallback:", error);
}

app.use(express.static(staticDir));
app.use(express.static(resolvedStaticDir));

// In source-mode layouts, index.html lives at repo root while favicons and
// other web assets live in ./public. Serve both so browser tabs can load icons.
const publicDir = path.join(resolvedStaticDir, "public");
if (await pathExists(publicDir)) {
app.use(express.static(publicDir));
}
}

// Health check endpoint
Expand Down Expand Up @@ -730,6 +760,12 @@ export async function createOrpcServer({
return next();
}

// Requests for explicit files (favicon, manifest, JS, etc.) should 404 when
// missing, rather than receiving HTML from SPA fallback.
if (path.extname(req.path)) {
return next();
Comment on lines +765 to +766

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve SPA fallback for dotted workspace IDs

The new path.extname(req.path) check classifies any URL whose final segment contains a dot as a static file request, so SPA fallback is skipped for routes like /workspace/project-1.2.3. That breaks browser-mode reload/direct-link flows because workspace URLs are path-based (/workspace/${encodeURIComponent(id)} in src/browser/contexts/RouterContext.tsx) and legacy IDs derived from workspace basenames can include dots (generateLegacyId in src/node/config.ts). In that scenario the server now returns 404 instead of index.html, preventing the app from loading.

Useful? React with 👍 / 👎.

}

if (spaIndexHtml !== null) {
res.setHeader("Content-Type", "text/html");
res.send(spaIndexHtml);
Expand Down
10 changes: 8 additions & 2 deletions src/node/services/serverService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { createOrpcServer, type OrpcServer, type OrpcServerOptions } from "@/node/orpc/server";
import {
createOrpcServer,
resolveOrpcStaticDir,
type OrpcServer,
type OrpcServerOptions,
} from "@/node/orpc/server";
import { ServerLockfile } from "./serverLockfile";
import type { ORPCContext } from "@/node/orpc/context";
import * as fs from "fs/promises";
Expand Down Expand Up @@ -223,7 +228,8 @@ export class ServerService {

this.apiAuthToken = options.authToken;

const staticDir = path.join(__dirname, "../..");
// Resolve the renderer root across both compiled dist/ and source-tree layouts.
const staticDir = await resolveOrpcStaticDir();
let serveStatic = options.serveStatic ?? false;
if (serveStatic) {
const indexPath = path.join(staticDir, "index.html");
Expand Down
Loading