Skip to content
Merged
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
6 changes: 3 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,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" sizes="180x180" href="/apple-touch-icon.png" vite-ignore />
<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" sizes="180x180" href="apple-touch-icon.png" vite-ignore />
<title>mux - coder multiplexer</title>
<style>
body {
Expand Down
8 changes: 4 additions & 4 deletions public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@
"name": "mux - coder multiplexer",
"short_name": "mux",
"description": "Parallel agentic development with Electron + React",
"start_url": "/",
"start_url": ".",
"display": "standalone",
"background_color": "#1e1e1e",
"theme_color": "#1e1e1e",
"orientation": "any",
"icons": [
{
"src": "/icon-192.png",
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
Expand All @@ -27,7 +27,7 @@
"name": "New Workspace",
"short_name": "New",
"description": "Create a new workspace",
"url": "/?action=new",
"url": "./?action=new",
"icons": []
}
]
Expand Down
6 changes: 5 additions & 1 deletion src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import { createProjectRefs } from "@/common/utils/multiProject";
import { MULTI_PROJECT_SIDEBAR_SECTION_ID } from "@/common/constants/multiProject";
import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults";
import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar";
import { prependInitialAppProxyBasePath } from "@/browser/utils/frontendBasePath";
import { LoadingScreen } from "@/browser/components/LoadingScreen/LoadingScreen";

function RootRouteShell(props: {
Expand Down Expand Up @@ -871,7 +872,10 @@ function AppInner() {
const handlePopState = () => {
// Re-push the correct URL from MemoryRouter, not the popped browser URL
const { pathname, search, hash } = locationRef.current;
const correctUrl = `${window.location.origin}${pathname}${search}${hash}`;
const correctUrl = new URL(
prependInitialAppProxyBasePath(`${pathname}${search}${hash}`),
window.location.origin
);
window.history.pushState({ mux: true }, "", correctUrl);
};

Expand Down
17 changes: 12 additions & 5 deletions src/browser/contexts/RouterContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
} from "react";
import { MemoryRouter, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import {
prependInitialAppProxyBasePath,
stripInitialAppProxyBasePathFromPathname,
} from "@/browser/utils/frontendBasePath";
import {
LAST_VISITED_ROUTE_KEY,
LAUNCH_BEHAVIOR_KEY,
Expand Down Expand Up @@ -177,7 +181,8 @@ function isRestorableRoute(route: unknown): route is string {

/** Get the initial route, falling back to the compatibility root entrypoint when needed. */
function getInitialRoute(): string {
const isStorybook = window.location.pathname.endsWith("iframe.html");
const routePathname = stripInitialAppProxyBasePathFromPathname(window.location.pathname);
const isStorybook = routePathname.endsWith("iframe.html");
const isStandalone = isStandalonePwa();
const navigationType = getStartupNavigationType();
const launchBehavior = !isStandalone
Expand All @@ -195,7 +200,7 @@ function getInitialRoute(): string {
// routes are special: fresh launches may ignore them, but explicit restore-style navigations
// such as hard reload/back-forward should reopen the same chat.
if (window.location.protocol !== "file:" && !isStorybook) {
const url = window.location.pathname + window.location.search;
const url = routePathname + window.location.search;
// Only use URL if it's a valid route (starts with /, not just "/" or empty)
if (url.startsWith("/") && url !== "/") {
if (!url.startsWith("/workspace/")) {
Expand Down Expand Up @@ -254,13 +259,15 @@ function useUrlSync(): void {
updatePersistedState(LAST_VISITED_ROUTE_KEY, url);
}

const currentRoutePathname = stripInitialAppProxyBasePathFromPathname(window.location.pathname);
// Skip in Storybook (conflicts with story navigation)
if (window.location.pathname.endsWith("iframe.html")) return;
if (currentRoutePathname.endsWith("iframe.html")) return;
// Skip in Electron (file:// reloads always boot through index.html; we restore via localStorage above)
if (window.location.protocol === "file:") return;

if (url !== window.location.pathname + window.location.search + window.location.hash) {
window.history.replaceState(null, "", url);
const browserUrl = prependInitialAppProxyBasePath(url);
if (browserUrl !== window.location.pathname + window.location.search + window.location.hash) {
window.history.replaceState(null, "", browserUrl);
}
}, [location.pathname, location.search, location.hash]);
}
Expand Down
5 changes: 3 additions & 2 deletions src/browser/contexts/ThemeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@ const THEME_COLORS: Record<ThemeMode, string> = {
"flexoki-dark": "#100f0f",
};

// Keep hrefs relative so a server-injected <base> preserves path-app prefixes.
const FAVICON_BY_SCHEME: Record<"light" | "dark", string> = {
light: "/favicon.ico",
dark: "/favicon-dark.ico",
light: "favicon.ico",
dark: "favicon-dark.ico",
};

/** Map theme mode to CSS color-scheme value */
Expand Down
3 changes: 2 additions & 1 deletion src/browser/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { installWindowOpenLocalhostProxyNormalization } from "@/browser/utils/wi
import { AppLoader } from "@/browser/components/AppLoader/AppLoader";
import { initTelemetry, trackAppStarted } from "@/common/telemetry";
import { initTitlebarInsets } from "@/browser/hooks/useDesktopTitlebar";
import { resolveBrowserAssetUrl } from "@/browser/utils/frontendBasePath";

// Initialize telemetry on app startup
try {
Expand Down Expand Up @@ -56,7 +57,7 @@ if ("serviceWorker" in navigator) {
if (isHttpProtocol) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/service-worker.js")
.register(resolveBrowserAssetUrl("service-worker.js"))
.then((registration) => {
console.log("Service Worker registered:", registration);
})
Expand Down
20 changes: 2 additions & 18 deletions src/browser/utils/backendBaseUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,9 @@
* prefix, so the frontend must include it when constructing URLs.
*/

// Non-greedy so we match the *first* "/apps/<slug>" segment in nested routes.
const APP_PROXY_BASE_PATH_RE = /(.*?\/apps\/[^/]+)(?:\/|$)/;
import { getAppProxyBasePathFromPathname } from "@/common/appProxyBasePath";

/**
* Returns the path prefix up to and including `/apps/<slug>`.
*
* Examples:
* - "/@u/ws/apps/mux/" -> "/@u/ws/apps/mux"
* - "/@u/ws/apps/mux/settings" -> "/@u/ws/apps/mux"
*/
export function getAppProxyBasePathFromPathname(pathname: string): string | null {
const match = APP_PROXY_BASE_PATH_RE.exec(pathname);
if (!match) {
return null;
}

const basePath = match[1];
return basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
}
export { getAppProxyBasePathFromPathname } from "@/common/appProxyBasePath";

function stripTrailingSlashes(url: string): string {
return url.replace(/\/+$/, "");
Expand Down
33 changes: 33 additions & 0 deletions src/browser/utils/frontendBasePath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getAppProxyBasePathFromPathname, stripAppProxyBasePath } from "@/common/appProxyBasePath";

export const INITIAL_APP_PROXY_BASE_PATH =
typeof window === "undefined" ? null : getAppProxyBasePathFromPathname(window.location.pathname);

function normalizeRootRelativePath(pathname: string): string {
return pathname.startsWith("/") ? pathname : `/${pathname}`;
}

export function stripInitialAppProxyBasePathFromPathname(pathname: string): string {
if (!INITIAL_APP_PROXY_BASE_PATH) {
return pathname;
}

const strippedPathname = stripAppProxyBasePath(pathname);
return strippedPathname.basePath === INITIAL_APP_PROXY_BASE_PATH
? strippedPathname.routePathname
: pathname;
}

export function prependInitialAppProxyBasePath(pathname: string): string {
const rootRelativePathname = normalizeRootRelativePath(pathname);
return INITIAL_APP_PROXY_BASE_PATH
? `${INITIAL_APP_PROXY_BASE_PATH}${rootRelativePathname}`
: rootRelativePathname;
}

export function resolveBrowserAssetUrl(pathname: string): string {
const proxiedPathname = prependInitialAppProxyBasePath(pathname);
return typeof document === "undefined"
? proxiedPathname
: new URL(proxiedPathname, document.baseURI).toString();
}
4 changes: 3 additions & 1 deletion src/browser/utils/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import type { RouterClient } from "@orpc/server";
import type { AppRouter } from "@/node/orpc/router";
import { resolveBrowserAssetUrl } from "@/browser/utils/frontendBasePath";

type APIClient = RouterClient<AppRouter>;

Expand Down Expand Up @@ -37,8 +38,9 @@ export function openTerminalPopout(api: APIClient, workspaceId: string, sessionI
// In browser mode, we must open the window client-side
// The backend cannot open a window on the user's client
const params = new URLSearchParams({ workspaceId, sessionId });
const terminalUrl = resolveBrowserAssetUrl(`terminal.html?${params.toString()}`);
window.open(
`/terminal.html?${params.toString()}`,
terminalUrl,
`terminal-${workspaceId}-${Date.now()}`,
"width=1000,height=600,popup=yes"
);
Expand Down
66 changes: 66 additions & 0 deletions src/common/appProxyBasePath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test";

import { getAppProxyBasePathFromPathname, stripAppProxyBasePath } from "./appProxyBasePath";

describe("appProxyBasePath", () => {
test("detects the app proxy base path", () => {
expect(getAppProxyBasePathFromPathname("/@u/ws/apps/mux/")).toBe("/@u/ws/apps/mux");
expect(getAppProxyBasePathFromPathname("/@u/ws/apps/mux/settings/general")).toBe(
"/@u/ws/apps/mux"
);
expect(getAppProxyBasePathFromPathname("/@u/ws.agent/apps/mux/")).toBe("/@u/ws.agent/apps/mux");
expect(getAppProxyBasePathFromPathname("/@u/ws/agent/apps/mux/foo")).toBe(
"/@u/ws/agent/apps/mux"
);
expect(getAppProxyBasePathFromPathname("/@u/ws/apps/mux")).toBe("/@u/ws/apps/mux");
});

test("rejects root routes and unanchored app segments", () => {
expect(getAppProxyBasePathFromPathname("/projects/apps/other")).toBeNull();
expect(getAppProxyBasePathFromPathname("/")).toBeNull();
expect(getAppProxyBasePathFromPathname("/settings")).toBeNull();
expect(getAppProxyBasePathFromPathname("//bad.example/@u/ws/apps/mux")).toBeNull();
});

test("strips the app proxy base path from prefix-only requests", () => {
expect(stripAppProxyBasePath("/@u/ws/apps/mux/")).toEqual({
basePath: "/@u/ws/apps/mux",
routePathname: "/",
});
expect(stripAppProxyBasePath("/@u/ws/apps/mux")).toEqual({
basePath: "/@u/ws/apps/mux",
routePathname: "/",
});
});

test("strips the app proxy base path and preserves the route suffix", () => {
expect(stripAppProxyBasePath("/@u/ws/apps/mux/settings/general")).toEqual({
basePath: "/@u/ws/apps/mux",
routePathname: "/settings/general",
});
expect(stripAppProxyBasePath("/@u/ws.agent/apps/mux/")).toEqual({
basePath: "/@u/ws.agent/apps/mux",
routePathname: "/",
});
expect(stripAppProxyBasePath("/@u/ws/agent/apps/mux/foo")).toEqual({
basePath: "/@u/ws/agent/apps/mux",
routePathname: "/foo",
});
});

test("returns the original pathname when no app proxy base path is present", () => {
expect(stripAppProxyBasePath("/projects/apps/other")).toEqual({
basePath: null,
routePathname: "/projects/apps/other",
});
expect(stripAppProxyBasePath("/")).toEqual({ basePath: null, routePathname: "/" });
expect(stripAppProxyBasePath("/settings")).toEqual({
basePath: null,
routePathname: "/settings",
});
expect(stripAppProxyBasePath("//bad.example/@u/ws/apps/mux")).toEqual({
basePath: null,
routePathname: "//bad.example/@u/ws/apps/mux",
});
});
});
38 changes: 38 additions & 0 deletions src/common/appProxyBasePath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const APP_PROXY_BASE_PATH_RE = /^\/@[^/]+\/[^/]+(?:\/[^/]+)?\/apps\/[^/]+(?:\/|$)/;

function hasUnsafeLeadingDoubleSlash(pathname: string): boolean {
return pathname.startsWith("//");
}

function stripTrailingSlash(pathname: string): string {
return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
}

export function getAppProxyBasePathFromPathname(pathname: string): string | null {
if (hasUnsafeLeadingDoubleSlash(pathname)) {
return null;
}

const match = APP_PROXY_BASE_PATH_RE.exec(pathname);
if (!match) {
return null;
}

return stripTrailingSlash(match[0]);
}

export function stripAppProxyBasePath(pathname: string): {
basePath: string | null;
routePathname: string;
} {
const basePath = getAppProxyBasePathFromPathname(pathname);
if (!basePath) {
return { basePath: null, routePathname: pathname };
}

const routePathname = pathname.slice(basePath.length);
return {
basePath,
routePathname: routePathname.length === 0 ? "/" : routePathname,
};
}
Loading
Loading