authorgravatar for ishansachu1@gmail.comIshan <ishansachu1@gmail.com> 2026-06-06 00:37:18+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-05 12:07:18-07:00
logb76f161d551235e1de89ed85c747f14041ac5d99
tree45f42ac16e9b8c4f0cab5691e19ec4d6aee7ac66
parent75257d64e9108b6e1620acbf423462fba3e0b62e
signature Signed by PGP key B5690EEEBB952194

fix(desktop): stop looping macOS TCC permission prompts (#2745)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: Julius Marminge <jmarminge@gmail.com>

7 files changed, 130 insertions(+), 27 deletions(-)

apps/desktop/src/backend/DesktopServerExposure.test.ts+41-2
......@@ -64,6 +64,13 @@ function mockSpawnerLayer(statusJson = "{}") {
6464 );
6565}
6666
67function dieOnSpawnLayer() {
68 return Layer.succeed(
69 ChildProcessSpawner.ChildProcessSpawner,
70 ChildProcessSpawner.make(() => Effect.die("unexpected tailscale spawn")),
71 );
72}
73
6774function makeEnvironmentLayer(baseDir: string, env: Record<string, string | undefined> = {}) {
6875 return makeDesktopEnvironmentLayer({
6976 dirname: "/repo/apps/desktop/src",
......@@ -86,6 +93,7 @@ function makeLayer(input: {
8693 readonly baseDir: string;
8794 readonly networkInterfaces?: DesktopNetworkInterfaces;
8895 readonly env?: Record<string, string | undefined>;
96 readonly spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
8997}) {
9098 const env = { T3CODE_HOME: input.baseDir, ...input.env };
9199 const environmentLayer = makeEnvironmentLayer(input.baseDir, env);
......@@ -97,7 +105,7 @@ function makeLayer(input: {
97105 Layer.provideMerge(DesktopAppSettings.layer),
98106 Layer.provideMerge(NodeFileSystem.layer),
99107 Layer.provideMerge(NodeHttpClient.layerUndici),
100 Layer.provideMerge(mockSpawnerLayer()),
108 Layer.provideMerge(input.spawnerLayer ?? mockSpawnerLayer()),
101109 Layer.provideMerge(networkLayer),
102110 Layer.provideMerge(DesktopConfig.layerTest(env)),
103111 Layer.provideMerge(environmentLayer),
......@@ -116,13 +124,23 @@ const withHarness = <A, E, R>(
116124 | DesktopAppSettings.DesktopAppSettings
117125 >,
118126 env: Record<string, string | undefined> = {},
127 spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>,
119128) =>
120129 Effect.gen(function* () {
121130 const fileSystem = yield* FileSystem.FileSystem;
122131 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
123132 prefix: "t3-desktop-server-exposure-test-",
124133 });
125 return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces, env })));
134 return yield* effect.pipe(
135 Effect.provide(
136 makeLayer({
137 baseDir,
138 networkInterfaces,
139 env,
140 ...(spawnerLayer ? { spawnerLayer } : {}),
141 }),
142 ),
143 );
126144 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
127145
128146describe("DesktopServerExposure", () => {
......@@ -239,6 +257,27 @@ describe("DesktopServerExposure", () => {
239257 ),
240258 );
241259
260 it.effect("does not spawn the tailscale CLI while server exposure is local-only", () =>
261 withHarness(
262 lanNetworkInterfaces,
263 Effect.gen(function* () {
264 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
265 yield* serverExposure.configureFromSettings({ port: 4173 });
266 // mode stays at default "local-only", tailscaleServeEnabled stays false.
267
268 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
269 // Only the loopback endpoint; no tailscale spawn means the dieOnSpawnLayer
270 // would have crashed the test if the gate was missing.
271 assert.deepEqual(
272 endpoints.map((endpoint) => endpoint.httpBaseUrl),
273 ["http://127.0.0.1:4173/"],
274 );
275 }),
276 {},
277 dieOnSpawnLayer(),
278 ),
279 );
280
242281 it.effect("uses ConfigProvider desktop exposure overrides", () =>
243282 withHarness(
244283 lanNetworkInterfaces,
apps/desktop/src/backend/DesktopServerExposure.ts+25
......@@ -12,6 +12,7 @@ import type {
1212} from "@t3tools/contracts";
1313import * as Context from "effect/Context";
1414import * as Data from "effect/Data";
15import * as Duration from "effect/Duration";
1516import * as Effect from "effect/Effect";
1617import * as Layer from "effect/Layer";
1718import * as Option from "effect/Option";
......@@ -22,8 +23,11 @@ import { ChildProcessSpawner } from "effect/unstable/process";
2223import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts";
2324import * as DesktopConfig from "../app/DesktopConfig.ts";
2425import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
26import { readTailscaleStatus } from "@t3tools/tailscale";
2527import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts";
2628
29const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60);
30
2731export const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
2832const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
2933
......@@ -412,6 +416,18 @@ const make = Effect.gen(function* () {
412416 const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings;
413417 const stateRef = yield* Ref.make(initialRuntimeState());
414418
419 // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App
420 // Store Tailscale CLI lives inside Tailscale's sandbox container, so each
421 // spawn re-triggers the "Other apps" TCC prompt.
422 const cachedReadMagicDnsName = yield* Effect.cachedWithTTL(
423 readTailscaleStatus.pipe(
424 Effect.map((status) => status.magicDnsName),
425 Effect.orElseSucceed(() => null),
426 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
427 ),
428 TAILSCALE_STATUS_CACHE_TTL,
429 );
430
415431 const readNetworkInterfaces = networkInterfaces.read;
416432
417433 const getState = Ref.get(stateRef).pipe(Effect.map(toContractState));
......@@ -516,11 +532,20 @@ const make = Effect.gen(function* () {
516532 exposure: toResolvedExposure(state),
517533 customHttpsEndpointUrls: config.desktopHttpsEndpointUrls,
518534 });
535
536 // Don't spawn the Tailscale CLI when the user hasn't opted into any
537 // network exposure. The spawn itself triggers a macOS "Other apps"
538 // TCC prompt on Mac App Store Tailscale builds.
539 if (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) {
540 return coreEndpoints;
541 }
542
519543 const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({
520544 port: state.port,
521545 serveEnabled: state.tailscaleServeEnabled,
522546 servePort: state.tailscaleServePort,
523547 networkInterfaces: currentNetworkInterfaces,
548 readMagicDnsName: cachedReadMagicDnsName,
524549 }).pipe(
525550 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
526551 Effect.provideService(HttpClient.HttpClient, httpClient),
apps/desktop/src/backend/tailscaleEndpointProvider.test.ts+19
......@@ -104,6 +104,25 @@ describe("tailscale endpoint provider", () => {
104104 }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),
105105 );
106106
107 it.effect("uses an injected magic DNS name reader instead of spawning tailscale", () =>
108 Effect.gen(function* () {
109 let readerCalls = 0;
110 const endpoints = yield* resolveTailscaleAdvertisedEndpoints({
111 port: 3773,
112 networkInterfaces: {},
113 readMagicDnsName: Effect.sync(() => {
114 readerCalls += 1;
115 return "desktop.tail.ts.net";
116 }),
117 });
118 assert.equal(readerCalls, 1);
119 assert.deepEqual(
120 endpoints.map((endpoint) => endpoint.httpBaseUrl),
121 ["https://desktop.tail.ts.net/"],
122 );
123 }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),
124 );
125
107126 it.effect(
108127 "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable",
109128 () =>
apps/desktop/src/backend/tailscaleEndpointProvider.ts+12-4
......@@ -105,6 +105,11 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd
105105 readonly servePort?: number;
106106 readonly networkInterfaces: DesktopNetworkInterfaces;
107107 readonly statusJson?: string | null;
108 readonly readMagicDnsName?: Effect.Effect<
109 string | null,
110 never,
111 ChildProcessSpawner.ChildProcessSpawner
112 >;
108113 readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;
109114 }): Effect.fn.Return<
110115 readonly AdvertisedEndpoint[],
......@@ -112,12 +117,15 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd
112117 ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient
113118 > {
114119 const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input);
120 const readDnsName =
121 input.readMagicDnsName ??
122 readTailscaleStatus.pipe(
123 Effect.map((status) => status.magicDnsName),
124 Effect.catch(() => Effect.succeed<string | null>(null)),
125 );
115126 const dnsName =
116127 input.statusJson === undefined
117 ? yield* readTailscaleStatus.pipe(
118 Effect.map((status) => status.magicDnsName),
119 Effect.orElseSucceed(() => null),
120 )
128 ? yield* readDnsName
121129 : input.statusJson
122130 ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe(
123131 Effect.orElseSucceed(() => null),
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+15
......@@ -392,5 +392,20 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
392392 expect(error.detail).toBe("Relative filesystem browse paths require a current project.");
393393 }),
394394 );
395
396 it.effect("returns an empty listing when the OS denies directory access", () =>
397 Effect.gen(function* () {
398 const workspaceEntries = yield* WorkspaceEntries;
399 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-eacces-" });
400
401 const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
402 vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(denied);
403
404 const result = yield* workspaceEntries.browse({
405 partialPath: appendSeparator(cwd),
406 });
407 expect(result).toEqual({ parentPath: cwd, entries: [] });
408 }),
409 );
395410 });
396411});
apps/server/src/workspace/Layers/WorkspaceEntries.ts+12-1
......@@ -457,7 +457,18 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
457457 detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`,
458458 cause,
459459 }),
460 });
460 }).pipe(
461 // The user can deny macOS TCC prompts for the target dir (Documents,
462 // Downloads, Music, etc.); surface an empty listing instead of an
463 // error so the caller doesn't retry-loop the prompt.
464 Effect.catchIf(
465 (error) => {
466 const code = (error.cause as NodeJS.ErrnoException | undefined)?.code;
467 return code === "EACCES" || code === "EPERM";
468 },
469 () => Effect.succeed<Dirent[]>([]),
470 ),
471 );
461472
462473 const showHidden = endsWithSeparator || prefix.startsWith(".");
463474 const lowerPrefix = prefix.toLowerCase();
apps/web/src/components/CommandPalette.tsx+6-20
......@@ -562,11 +562,7 @@ function OpenCommandPaletteDialog() {
562562 !relativePathNeedsActiveProject,
563563 });
564564 const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES;
565 const {
566 filteredEntries: filteredBrowseEntries,
567 highlightedEntry: highlightedBrowseEntry,
568 exactEntry: exactBrowseEntry,
569 } = useMemo(
565 const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo(
570566 () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }),
571567 [browseEntries, browseFilterQuery, highlightedItemValue],
572568 );
......@@ -587,27 +583,17 @@ function OpenCommandPaletteDialog() {
587583 [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient],
588584 );
589585
590 // Prefetch the parent and the most likely next child so browse navigation
591 // stays warm without scanning every child directory in large trees.
586 // Prefetch only the parent (for back-navigation). Prefetching the
587 // highlighted child on every arrow-key press triggers a macOS TCC prompt
588 // whenever the highlighted entry is a permission-gated home dir (Music,
589 // Documents, Downloads, Desktop, etc.), so we wait for explicit navigation.
592590 useEffect(() => {
593591 if (!isBrowsing || filteredBrowseEntries.length === 0) return;
594592
595593 if (canNavigateUp(query)) {
596594 prefetchBrowsePath(getBrowseParentPath(query)!);
597595 }
598
599 const nextChild = highlightedBrowseEntry ?? exactBrowseEntry;
600 if (nextChild) {
601 prefetchBrowsePath(appendBrowsePathSegment(query, nextChild.name));
602 }
603 }, [
604 exactBrowseEntry,
605 filteredBrowseEntries.length,
606 highlightedBrowseEntry,
607 isBrowsing,
608 prefetchBrowsePath,
609 query,
610 ]);
596 }, [filteredBrowseEntries.length, isBrowsing, prefetchBrowsePath, query]);
611597
612598 const openProjectFromSearch = useMemo(
613599 () => async (project: (typeof projects)[number]) => {