Skip to content

Commit 4a0e982

Browse files
tarik02aidenybaijuliusmarmingecodexJustMarkDev
authored
sync: update from upstream (#31)
* planning * feat(preview): in-app browser preview panel Adds a desktop-only browser preview that lives in the right panel slot alongside plan/diff. Lets the user point an Electron <webview> at any URL — typed into a chrome-style URL bar, clicked from the empty-state list of detected localhost dev servers, or auto-opened by a project script with `previewUrl` set. Single-tab per thread. Server (Effect/Layers): - PreviewManager: per-(thread, tab) session metadata via SynchronizedRef + PubSub<PreviewEvent>; survives WS reconnect via `list`/replay. - PreviewPortScanner: lsof on macOS/Linux, TCP probe fallback on Windows; reference-counted polling so we only scan when subscribed. - WS RPC + streams (`preview.open|navigate|refresh|close|list|reportStatus`, `subscribePreviewEvents`, `subscribeDiscoveredLocalServers`). Desktop: - PreviewViewManager owns Chromium WebContents per tab, mediates navigation/zoom/devtools/clear-storage. registerWebview gates by webContents.getType() === "webview" and host-window match. - IPC channels for create/close/register/navigate/back/forward/refresh/ zoom/hardReload/openDevTools/clearCookies/clearCache/getBrowserPartition. - Forwards app-level shortcuts (mod+shift+J, mod+K, mod+,, mod+W) from the webview back to the main window. - Persisted browser session partition (cookies, cache). Web: - PreviewPanel/PreviewView/PreviewWebview render the surface; chrome row with back/forward/refresh + URL input + Open-in-browser + 3-dot menu (Hard reload, DevTools, Zoom −/+/reset, Clear cookies/cache). - usePreviewSession subscribes to server events; usePreviewBridge mirrors desktop state into the store and forwards Loading→Success/ LoadFailed back to the server. - previewStateStore: per-thread snapshot + desktopOverlay + recently- seen URLs (Zustand). - rightPanelStore arbitrates plan vs. preview vs. diff; ChatView's toggles strip the `?diff=1` URL hint when switching to preview and vice versa so the panels are mutually exclusive. - Top-nav Globe toggle in ChatHeader (desktop builds only) and a `mod+shift+J` keybinding routed via a typed previewActionBus. - PreviewEmptyState lists detected localhost servers (scanner + configured project URLs + recently-seen) with live "listening" pulse. - PreviewUnreachable: theme-aware port of Chromium's "site can't be reached" page. - Resizable inline panel (RightPanelResizeHandle + useResizableWidth); width persists to localStorage on drag-end. - Terminal link "Open in preview" context-menu integration for loopback URLs. Contracts: - preview.ts schemas (PreviewSessionSnapshot, PreviewNavStatus, PreviewEvent, RPC inputs/results, DiscoveredLocalServer). - ProjectScript schema gains optional `previewUrl` + `autoOpenPreview`. - New keybinding commands: preview.toggle/refresh/focusUrl/zoomIn/Out/ resetZoom; new `when:` contexts `previewFocus` / `previewOpen`. Shared: - @t3tools/shared/preview: normalizePreviewUrl, isPreviewableUrl, isLoopbackHost, newPreviewTabId, LSOF_LOCAL_HOST_TOKENS. Tests: - contracts: schema decode tests for all preview events/snapshots/inputs. - shared: URL normalization coverage. - server: PreviewManager (open/navigate/reportStatus/refresh/close, multi-subscriber isolation, idempotency); PortScanner (lsof parsing including IPv6, TCP probe, reference-counted polling). - web: previewStateStore (per-tab event application, dedupe, reconnect recovery); rightPanelStore arbitration. * fix * feat(preview): element-pick attachments + sandboxed picker preload Adds an in-page element picker to the preview browser. Clicking the crosshair button in the chrome row activates a blue-highlight picker inside the guest webview; clicking an element captures its component name (via react-grab), source location, html/css preview, and selector, then attaches it to the chat composer as a chip that serializes into an `<element_context>` block in the outgoing message. Architecture: - Per-`<webview>` preload bundle (`preview-pick-preload.cjs`) renders the overlay, hosts the picker event loop, and bubbles the picked payload back to main via the per-WebContents `wc.ipc` channel (not `sendToHost`, which only fires on the host renderer's <webview> element and never reaches main). - Main coordinates via `PreviewViewManager.pickElement(tabId)`, which cancels any in-flight session, force-focuses the guest (so the first click on a remote page actually reaches the preload), then awaits the payload. User-initiated cancels (Escape, beforeunload) echo `null` back to main; main-initiated cancels and supersession tear down silently to avoid the new-pick-resolves-with-stale-null race. - Renderer fetches partition + webPreferences + preload URL in a single `getPreviewConfig()` IPC call, snapshots the previously-focused host element before triggering a pick, and restores focus when the pick resolves so the user's textarea cursor isn't lost. Security posture for the guest webview: - `webpreferences="contextIsolation=false,sandbox=true,nodeIntegration=false"` centralized in `preview-webview-preferences.ts`. contextIsolation off is required so react-grab's `getElementContext` can reach the page's React DevTools hook on `globalThis`. sandbox stays on so the page cannot reach Node APIs even with shared globals (without it, the preload's `require` would land on the page's `globalThis` and any third-party site could send arbitrary IPC to main). - Defense in depth: a `will-attach-webview` handler in main, gated on the preview partition, force-pins `sandbox: true`, all `nodeIntegration*: false`, and the absolute preload PATH (not URL — that field rejects file:// URLs with "preload script must have absolute path" and silently disables the picker). Composer + transcript integration: - New `elementContexts` slice in `composerDraftStore` (mirrors the terminal-context slice: dedup by selector+tag+component+url, persist via partializer, restore on send-failure retry). - `ComposerPendingElementContexts` chip row above the editor. - `deriveDisplayedUserMessageState` now strips both `<element_context>` AND `<terminal_context>` blocks (element first, since it's appended last) and exposes element entries to `MessagesTimeline`, which renders them as compact chips beneath the message body. - Pick button is disabled with explanatory tooltip when the page failed to load (the React `<PreviewUnreachable>` overlay covers the webview, so picks would silently dangle otherwise). Tests added: - `preview-webview-preferences.test.ts` locks down the security flags (contextIsolation=false, sandbox=true, nodeIntegration=false, no whitespace, only true/false literal values). - `preview-pick-label-position.test.ts` covers the floating-label clamp/flip math (no off-screen overflow, flip-below when no room above, etc.). - `picked-element-payload.test.ts` validator coverage. - `elementContext.test.ts` for the serialization round-trip, normalization, dedup, and label formatting. - `composerDraftStore.test.ts` element-contexts slice (add, dedup, remove, set, clear, persistence round-trip). - `ChatView.logic.test.ts` sendable-content-with-element-only. Build: new `tsdown` entry inlines react-grab + bippy into the picker preload bundle (~59KB / 19KB gzipped). * fix(preview): port browser preview to current main Co-authored-by: codex <codex@users.noreply.github.com> * fix(preview): initialize and open browser reliably Co-authored-by: codex <codex@users.noreply.github.com> * fix(preview): declare RPC authorization scopes Co-authored-by: codex <codex@users.noreply.github.com> * Add preview annotation capture tooling - Add structured annotation payload validation and tests - Update preview preload to capture selected elements, regions, and strokes - Wire new preview annotation UI into the web app Co-authored-by: codex <codex@users.noreply.github.com> * Add shared MCP preview automation * Refine collaborative browser preview Co-authored-by: codex <codex@users.noreply.github.com> * Port browser preview annotations to desktop - Add IPC and runtime plumbing for preview annotation theming - Generate and ship annotation CSS for the desktop overlay - Add pointer and artifact handling for browser preview interactions * Refactor MCP services into top-level modules - Move MCP session registry and preview broker out of `Layers/` and `Services/` - Update imports, tests, and server wiring to use the new module layout * Refactor desktop preview IPC onto shared manager - Move preview session and IPC wiring into the new preview module - Tighten IPC validation with schema-based handlers - Update preview asset paths and tests for the browser preview port * Port preview manager to Effect-based browser sessions - derive preview partitions through `BrowserSession` - serialize session state and async preview control flow - update tests for screenshot, automation, and partition behavior * Scope preview listeners and control sessions - Tie preview and debugger listeners to Effect scopes - Factor shared automation helpers for snapshot and input handling - Improve cleanup for browser preview sessions and port scanning * Add SWR preview session state and resubscribe handling - Fetch preview sessions through atom-backed SWR state - Recover browser preview sessions after reconnects - Ignore older streamed snapshots when SWR revalidates * Prevent stale preview snapshots from resurrecting sessions - Track preview store revisions per thread - Ignore stale SWR results while revalidating - Avoid restoring closed sessions from outdated data * Unify browser asset preview routing - Replace attachment and favicon routes with signed asset URLs - Harden workspace and attachment asset resolution - Update browser preview components and shared contracts * Fix preview CI test fixtures * Fix terminal browser test mock * Restore terminal drawer header toggle * Use real preview tooltips * Document browser preview phase 0.5 findings and plans - Add phase 0 and 0.5 ADRs, findings, and spike notes - Update browser preview docs and supporting UI/test files - Record the chosen renderer, automation, recording, tunnel, and input decisions * Remove outdated plans for shared HTTP MCP server and visible preview browser automation via CDP. Consolidate and refine the architecture and process models for the new preview automation framework, including updated contracts, server-side broker, and desktop integration. Introduce new WS methods for client communication and enhance security measures for token management. Ensure comprehensive testing strategies are in place for all components. * rm test artifacts * Fix Bitbucket source control availability toggle * Guard VCS status updates against stale targets (pingdotgg#3084) * Wrap authorized clients in fading scroll area (pingdotgg#3085) * Preserve diff surface when toggling right panel (pingdotgg#3083) * fix(desktop): mac start:desktop crash from rewritten framework symlinks (pingdotgg#3058) * refactor: resolve host process state through Effect (pingdotgg#2959) Co-authored-by: Julius Marminge <julius@mac.lan> Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * Add workspace file browser and preview panel (pingdotgg#3087) * Unify compact subheaders and diff file navigation - Add shared compact subheader styling for preview, file, and diff panels - Open diff files in the thread file viewer when available - Fix macOS cursor auto-hide and Option-modified shortcut matching * Tighten workspace control spacing in the header - Align header controls with the 12px right inset - Use a smaller control offset in window controls overlay mode * Show disabled reasons for unavailable right panel surfaces (pingdotgg#3093) * [codex] Fix first browser annotation capture (pingdotgg#3095) Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com> * Use `fff` for workspace search queries (pingdotgg#3099) * [codex] Fix terminal line height for QR readability (pingdotgg#3096) * [codex] Trace first-party relay clients (pingdotgg#2995) Co-authored-by: codex <codex@users.noreply.github.com> * fix sync merge gaps for terminal runtime env wiring restore upstream projectScriptRuntimeEnv and thread drawer runtime env props dropped during fork merge Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Aiden Bai <aiden.bai05@gmail.com> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: JustMarkDev <procopiomarco@protonmail.com> Co-authored-by: Icarus Wings <10465470+TheIcarusWings@users.noreply.github.com> Co-authored-by: Julius Marminge <julius@mac.lan> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Theo Browne <me@t3.gg> Co-authored-by: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
2 parents 213e5d4 + 77451ff commit 4a0e982

363 files changed

Lines changed: 28865 additions & 13566 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/release.yml

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,12 +245,46 @@ jobs:
245245
clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }}
246246
relay_url: ${{ steps.public_config.outputs.relay_url }}
247247
env:
248+
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
249+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
248250
RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }}
249251
RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }}
250252
CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
251253
CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }}
252254
CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }}
253255
steps:
256+
- name: Checkout
257+
uses: actions/checkout@v6
258+
with:
259+
ref: ${{ needs.preflight.outputs.ref }}
260+
261+
- name: Setup Vite+
262+
uses: voidzero-dev/setup-vp@v1
263+
with:
264+
node-version-file: package.json
265+
cache: true
266+
run-install: |
267+
args:
268+
- --filter=t3code-relay...
269+
270+
- id: relay_state
271+
name: Read production relay tracing config
272+
shell: bash
273+
run: |
274+
vp run --filter t3code-relay deploy \
275+
--stage prod \
276+
--read-state \
277+
--github-output \
278+
--github-env-file "$RUNNER_TEMP/relay-client-tracing.env"
279+
280+
- name: Upload relay client tracing config
281+
uses: actions/upload-artifact@v7
282+
with:
283+
name: relay-client-tracing-config
284+
path: ${{ runner.temp }}/relay-client-tracing.env
285+
if-no-files-found: error
286+
retention-days: 1
287+
254288
- id: public_config
255289
name: Resolve production relay public config
256290
shell: bash
@@ -337,6 +371,20 @@ jobs:
337371
cache: true
338372
run-install: true
339373

374+
- name: Download relay client tracing config
375+
uses: actions/download-artifact@v8
376+
with:
377+
name: relay-client-tracing-config
378+
path: ${{ runner.temp }}/relay-client-tracing
379+
380+
- name: Load relay client tracing config
381+
shell: bash
382+
run: |
383+
config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env"
384+
tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")"
385+
echo "::add-mask::$tracing_token"
386+
cat "$config_path" >> "$GITHUB_ENV"
387+
340388
- name: Align package versions to release version
341389
run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
342390

@@ -620,6 +668,20 @@ jobs:
620668
- --filter=@t3tools/web...
621669
- --filter=@t3tools/scripts...
622670
671+
- name: Download relay client tracing config
672+
uses: actions/download-artifact@v8
673+
with:
674+
name: relay-client-tracing-config
675+
path: ${{ runner.temp }}/relay-client-tracing
676+
677+
- name: Load relay client tracing config
678+
shell: bash
679+
run: |
680+
config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env"
681+
tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")"
682+
echo "::add-mask::$tracing_token"
683+
cat "$config_path" >> "$GITHUB_ENV"
684+
623685
- name: Align package versions to release version
624686
run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
625687

@@ -782,6 +844,20 @@ jobs:
782844
- --filter=@t3tools/scripts...
783845
- --filter=@t3tools/web...
784846
847+
- name: Download relay client tracing config
848+
uses: actions/download-artifact@v8
849+
with:
850+
name: relay-client-tracing-config
851+
path: ${{ runner.temp }}/relay-client-tracing
852+
853+
- name: Load relay client tracing config
854+
shell: bash
855+
run: |
856+
config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env"
857+
tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")"
858+
echo "::add-mask::$tracing_token"
859+
cat "$config_path" >> "$GITHUB_ENV"
860+
785861
- name: Align package versions to release version
786862
run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
787863

@@ -828,6 +904,9 @@ jobs:
828904
--build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \
829905
--build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \
830906
--build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \
907+
--build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \
908+
--build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \
909+
--build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=${T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN:-}" \
831910
--build-env "VITE_HOSTED_APP_URL=$router_url" \
832911
--build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"
833912
)"

apps/desktop/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,16 @@
2020
"@t3tools/tailscale": "workspace:*",
2121
"effect": "catalog:",
2222
"electron": "41.5.0",
23-
"electron-updater": "^6.6.2"
23+
"electron-updater": "^6.6.2",
24+
"playwright-core": "1.60.0",
25+
"react-grab": "^0.1.32"
2426
},
2527
"devDependencies": {
2628
"@effect/vitest": "catalog:",
2729
"@types/node": "catalog:",
2830
"cross-env": "^10.1.0",
2931
"electron-builder": "26.8.1",
32+
"tailwindcss": "^4.0.0",
3033
"vite-plus": "catalog:"
3134
},
3235
"productName": "T3 Code (Alpha)"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { readFile, writeFile } from "node:fs/promises";
2+
import { createRequire } from "node:module";
3+
import { dirname, join } from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
6+
import { compile } from "tailwindcss";
7+
8+
const directory = dirname(fileURLToPath(import.meta.url));
9+
const appRoot = join(directory, "..");
10+
const sourcePath = join(appRoot, "src", "preview", "Annotation.css");
11+
const preloadPath = join(appRoot, "src", "preview", "PickPreload.ts");
12+
const outputPath = join(appRoot, "src", "preview", "AnnotationStyles.generated.ts");
13+
const require = createRequire(import.meta.url);
14+
const tailwindRoot = dirname(require.resolve("tailwindcss/package.json"));
15+
16+
const [annotationSource, preloadSource, themeSource, preflightSource] = await Promise.all([
17+
readFile(sourcePath, "utf8"),
18+
readFile(preloadPath, "utf8"),
19+
readFile(join(tailwindRoot, "theme.css"), "utf8"),
20+
readFile(join(tailwindRoot, "preflight.css"), "utf8"),
21+
]);
22+
23+
const candidates = new Set(
24+
Array.from(preloadSource.matchAll(/!?-?[A-Za-z0-9_:@/.[\]()%,-]+/g), (match) => match[0]),
25+
);
26+
const compilerInput = [
27+
themeSource,
28+
preflightSource,
29+
annotationSource.replace('@import "tailwindcss";', "@tailwind utilities;"),
30+
].join("\n");
31+
const compiler = await compile(compilerInput, { base: appRoot });
32+
const css = compiler.build([...candidates]);
33+
const encodedCss = `'${css
34+
.replaceAll("\\", "\\\\")
35+
.replaceAll("'", "\\'")
36+
.replaceAll("\r", "\\r")
37+
.replaceAll("\n", "\\n")}'`;
38+
const moduleSource = `// Generated by scripts/build-preview-annotation-css.mjs. Do not edit.\nexport const previewAnnotationStyles =\n ${encodedCss};\n`;
39+
40+
await writeFile(outputPath, moduleSource);

apps/desktop/scripts/dev-electron.mjs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { spawn, spawnSync } from "node:child_process";
22
import { watch } from "node:fs";
3+
import * as NodeOS from "node:os";
34
import { join } from "node:path";
45

5-
import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs";
6+
import {
7+
desktopDir,
8+
resolveDevProtocolClient,
9+
resolveElectronLaunchCommand,
10+
} from "./electron-launcher.mjs";
611
import { waitForResources } from "./wait-for-resources.mjs";
712

813
const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
@@ -29,6 +34,8 @@ const forcedShutdownTimeoutMs = 1_500;
2934
const restartDebounceMs = 120;
3035
const childTreeGracePeriodMs = 1_200;
3136
const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim();
37+
// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime.
38+
const hostPlatform = NodeOS.platform();
3239

3340
await waitForResources({
3441
baseDir: desktopDir,
@@ -53,15 +60,15 @@ const expectedExits = new WeakSet();
5360
const watchers = [];
5461

5562
function killChildTreeByPid(pid, signal) {
56-
if (process.platform === "win32" || typeof pid !== "number") {
63+
if (hostPlatform === "win32" || typeof pid !== "number") {
5764
return;
5865
}
5966

6067
spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" });
6168
}
6269

6370
function cleanupStaleDevApps() {
64-
if (process.platform === "win32") {
71+
if (hostPlatform === "win32") {
6572
return;
6673
}
6774

@@ -79,7 +86,8 @@ function startApp() {
7986
const launchArgs = devProtocolClient
8087
? electronArgs
8188
: [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"];
82-
const app = spawn(resolveElectronPath(), launchArgs, {
89+
const electronCommand = resolveElectronLaunchCommand(launchArgs);
90+
const app = spawn(electronCommand.electronPath, electronCommand.args, {
8391
cwd: desktopDir,
8492
env: childEnv,
8593
stdio: "inherit",
@@ -189,7 +197,7 @@ function startWatchers() {
189197
}
190198

191199
function killChildTree(signal) {
192-
if (process.platform === "win32") {
200+
if (hostPlatform === "win32") {
193201
return;
194202
}
195203

apps/desktop/scripts/electron-launcher.mjs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
writeFileSync,
1515
} from "node:fs";
1616
import { createRequire } from "node:module";
17+
import * as NodeOS from "node:os";
1718
import { basename, dirname, join, resolve } from "node:path";
1819
import { fileURLToPath } from "node:url";
1920
import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs";
@@ -30,9 +31,11 @@ export const APP_BUNDLE_ID = isDevelopment
3031
? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}`
3132
: "com.t3tools.t3code";
3233
const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"];
33-
const LAUNCHER_VERSION = 10;
34+
const LAUNCHER_VERSION = 11;
3435
const defaultIconPath = join(desktopDir, "resources", "icon.icns");
3536
const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png");
37+
// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime.
38+
const hostPlatform = NodeOS.platform();
3639

3740
function resolveDevelopmentProtocolCallbackPort() {
3841
const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10);
@@ -295,7 +298,11 @@ function buildMacLauncher(electronBinaryPath) {
295298
}
296299

297300
rmSync(targetAppBundlePath, { recursive: true, force: true });
298-
cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true });
301+
// verbatimSymlinks keeps the framework's relative symlinks intact
302+
// (e.g. Resources -> Versions/Current/Resources). Without it cpSync
303+
// rewrites them to absolute paths into node_modules, which escape the
304+
// bundle and crash sandboxed helper processes (icudtl.dat not found).
305+
cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true, verbatimSymlinks: true });
299306
patchMainBundleInfoPlist(targetAppBundlePath, iconPath);
300307
patchHelperBundleInfoPlists(targetAppBundlePath);
301308
if (isDevelopment) {
@@ -307,21 +314,54 @@ function buildMacLauncher(electronBinaryPath) {
307314
return targetBinaryPath;
308315
}
309316

317+
function isLinuxSetuidSandboxConfigured(electronBinaryPath) {
318+
if (hostPlatform !== "linux") {
319+
return true;
320+
}
321+
322+
const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox");
323+
try {
324+
const sandboxStat = statSync(sandboxPath);
325+
return sandboxStat.uid === 0 && (sandboxStat.mode & 0o4777) === 0o4755;
326+
} catch {
327+
return false;
328+
}
329+
}
330+
331+
function resolveLinuxSandboxArgs(electronBinaryPath) {
332+
if (isLinuxSetuidSandboxConfigured(electronBinaryPath)) {
333+
return [];
334+
}
335+
336+
console.warn(
337+
"[desktop-launcher] Electron chrome-sandbox is not root-owned with mode 4755; launching local Electron with --no-sandbox.",
338+
);
339+
return ["--no-sandbox"];
340+
}
341+
310342
export function resolveElectronPath() {
311343
ensureElectronRuntime();
312344

313345
const require = createRequire(import.meta.url);
314346
const electronBinaryPath = require("electron");
315347

316-
if (process.platform !== "darwin") {
348+
if (hostPlatform !== "darwin") {
317349
return electronBinaryPath;
318350
}
319351

320352
return buildMacLauncher(electronBinaryPath);
321353
}
322354

355+
export function resolveElectronLaunchCommand(args = []) {
356+
const electronPath = resolveElectronPath();
357+
return {
358+
electronPath,
359+
args: [...resolveLinuxSandboxArgs(electronPath), ...args],
360+
};
361+
}
362+
323363
export function resolveDevProtocolClient() {
324-
if (process.platform !== "darwin" || !isDevelopment) {
364+
if (hostPlatform !== "darwin" || !isDevelopment) {
325365
return null;
326366
}
327367

0 commit comments

Comments
 (0)