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 = "{}") {...@@ -64,6 +64,13 @@ function mockSpawnerLayer(statusJson = "{}") {
64 );64 );
65}65}
6666
67function dieOnSpawnLayer() {
68 return Layer.succeed(
69 ChildProcessSpawner.ChildProcessSpawner,
70 ChildProcessSpawner.make(() => Effect.die("unexpected tailscale spawn")),
71 );
72}
73
67function makeEnvironmentLayer(baseDir: string, env: Record<string, string | undefined> = {}) {74function makeEnvironmentLayer(baseDir: string, env: Record<string, string | undefined> = {}) {
68 return makeDesktopEnvironmentLayer({75 return makeDesktopEnvironmentLayer({
69 dirname: "/repo/apps/desktop/src",76 dirname: "/repo/apps/desktop/src",
...@@ -86,6 +93,7 @@ function makeLayer(input: {...@@ -86,6 +93,7 @@ function makeLayer(input: {
86 readonly baseDir: string;93 readonly baseDir: string;
87 readonly networkInterfaces?: DesktopNetworkInterfaces;94 readonly networkInterfaces?: DesktopNetworkInterfaces;
88 readonly env?: Record<string, string | undefined>;95 readonly env?: Record<string, string | undefined>;
96 readonly spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
89}) {97}) {
90 const env = { T3CODE_HOME: input.baseDir, ...input.env };98 const env = { T3CODE_HOME: input.baseDir, ...input.env };
91 const environmentLayer = makeEnvironmentLayer(input.baseDir, env);99 const environmentLayer = makeEnvironmentLayer(input.baseDir, env);
...@@ -97,7 +105,7 @@ function makeLayer(input: {...@@ -97,7 +105,7 @@ function makeLayer(input: {
97 Layer.provideMerge(DesktopAppSettings.layer),105 Layer.provideMerge(DesktopAppSettings.layer),
98 Layer.provideMerge(NodeFileSystem.layer),106 Layer.provideMerge(NodeFileSystem.layer),
99 Layer.provideMerge(NodeHttpClient.layerUndici),107 Layer.provideMerge(NodeHttpClient.layerUndici),
100 Layer.provideMerge(mockSpawnerLayer()),108 Layer.provideMerge(input.spawnerLayer ?? mockSpawnerLayer()),
101 Layer.provideMerge(networkLayer),109 Layer.provideMerge(networkLayer),
102 Layer.provideMerge(DesktopConfig.layerTest(env)),110 Layer.provideMerge(DesktopConfig.layerTest(env)),
103 Layer.provideMerge(environmentLayer),111 Layer.provideMerge(environmentLayer),
...@@ -116,13 +124,23 @@ const withHarness = <A, E, R>(...@@ -116,13 +124,23 @@ const withHarness = <A, E, R>(
116 | DesktopAppSettings.DesktopAppSettings124 | DesktopAppSettings.DesktopAppSettings
117 >,125 >,
118 env: Record<string, string | undefined> = {},126 env: Record<string, string | undefined> = {},
127 spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>,
119) =>128) =>
120 Effect.gen(function* () {129 Effect.gen(function* () {
121 const fileSystem = yield* FileSystem.FileSystem;130 const fileSystem = yield* FileSystem.FileSystem;
122 const baseDir = yield* fileSystem.makeTempDirectoryScoped({131 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
123 prefix: "t3-desktop-server-exposure-test-",132 prefix: "t3-desktop-server-exposure-test-",
124 });133 });
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 );
126 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);144 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
127145
128describe("DesktopServerExposure", () => {146describe("DesktopServerExposure", () => {
...@@ -239,6 +257,27 @@ describe("DesktopServerExposure", () => {...@@ -239,6 +257,27 @@ describe("DesktopServerExposure", () => {
239 ),257 ),
240 );258 );
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
242 it.effect("uses ConfigProvider desktop exposure overrides", () =>281 it.effect("uses ConfigProvider desktop exposure overrides", () =>
243 withHarness(282 withHarness(
244 lanNetworkInterfaces,283 lanNetworkInterfaces,
apps/desktop/src/backend/DesktopServerExposure.ts+25
...@@ -12,6 +12,7 @@ import type {...@@ -12,6 +12,7 @@ import type {
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import * as Context from "effect/Context";13import * as Context from "effect/Context";
14import * as Data from "effect/Data";14import * as Data from "effect/Data";
15import * as Duration from "effect/Duration";
15import * as Effect from "effect/Effect";16import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";17import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";18import * as Option from "effect/Option";
...@@ -22,8 +23,11 @@ import { ChildProcessSpawner } from "effect/unstable/process";...@@ -22,8 +23,11 @@ import { ChildProcessSpawner } from "effect/unstable/process";
22import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts";23import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts";
23import * as DesktopConfig from "../app/DesktopConfig.ts";24import * as DesktopConfig from "../app/DesktopConfig.ts";
24import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";25import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
26import { readTailscaleStatus } from "@t3tools/tailscale";
25import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts";27import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts";
2628
29const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60);
30
27export const DESKTOP_LOOPBACK_HOST = "127.0.0.1";31export const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
28const DESKTOP_LAN_BIND_HOST = "0.0.0.0";32const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
2933
...@@ -412,6 +416,18 @@ const make = Effect.gen(function* () {...@@ -412,6 +416,18 @@ const make = Effect.gen(function* () {
412 const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings;416 const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings;
413 const stateRef = yield* Ref.make(initialRuntimeState());417 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
415 const readNetworkInterfaces = networkInterfaces.read;431 const readNetworkInterfaces = networkInterfaces.read;
416432
417 const getState = Ref.get(stateRef).pipe(Effect.map(toContractState));433 const getState = Ref.get(stateRef).pipe(Effect.map(toContractState));
...@@ -516,11 +532,20 @@ const make = Effect.gen(function* () {...@@ -516,11 +532,20 @@ const make = Effect.gen(function* () {
516 exposure: toResolvedExposure(state),532 exposure: toResolvedExposure(state),
517 customHttpsEndpointUrls: config.desktopHttpsEndpointUrls,533 customHttpsEndpointUrls: config.desktopHttpsEndpointUrls,
518 });534 });
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
519 const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({543 const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({
520 port: state.port,544 port: state.port,
521 serveEnabled: state.tailscaleServeEnabled,545 serveEnabled: state.tailscaleServeEnabled,
522 servePort: state.tailscaleServePort,546 servePort: state.tailscaleServePort,
523 networkInterfaces: currentNetworkInterfaces,547 networkInterfaces: currentNetworkInterfaces,
548 readMagicDnsName: cachedReadMagicDnsName,
524 }).pipe(549 }).pipe(
525 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),550 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
526 Effect.provideService(HttpClient.HttpClient, httpClient),551 Effect.provideService(HttpClient.HttpClient, httpClient),
apps/desktop/src/backend/tailscaleEndpointProvider.test.ts+19
...@@ -104,6 +104,25 @@ describe("tailscale endpoint provider", () => {...@@ -104,6 +104,25 @@ describe("tailscale endpoint provider", () => {
104 }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),104 }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),
105 );105 );
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
107 it.effect(126 it.effect(
108 "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable",127 "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable",
109 () =>128 () =>
apps/desktop/src/backend/tailscaleEndpointProvider.ts+12-4
...@@ -105,6 +105,11 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd...@@ -105,6 +105,11 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd
105 readonly servePort?: number;105 readonly servePort?: number;
106 readonly networkInterfaces: DesktopNetworkInterfaces;106 readonly networkInterfaces: DesktopNetworkInterfaces;
107 readonly statusJson?: string | null;107 readonly statusJson?: string | null;
108 readonly readMagicDnsName?: Effect.Effect<
109 string | null,
110 never,
111 ChildProcessSpawner.ChildProcessSpawner
112 >;
108 readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;113 readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;
109 }): Effect.fn.Return<114 }): Effect.fn.Return<
110 readonly AdvertisedEndpoint[],115 readonly AdvertisedEndpoint[],
...@@ -112,12 +117,15 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd...@@ -112,12 +117,15 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd
112 ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient117 ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient
113 > {118 > {
114 const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input);119 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 );
115 const dnsName =126 const dnsName =
116 input.statusJson === undefined127 input.statusJson === undefined
117 ? yield* readTailscaleStatus.pipe(128 ? yield* readDnsName
118 Effect.map((status) => status.magicDnsName),
119 Effect.orElseSucceed(() => null),
120 )
121 : input.statusJson129 : input.statusJson
122 ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe(130 ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe(
123 Effect.orElseSucceed(() => null),131 Effect.orElseSucceed(() => null),
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+15
...@@ -392,5 +392,20 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {...@@ -392,5 +392,20 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
392 expect(error.detail).toBe("Relative filesystem browse paths require a current project.");392 expect(error.detail).toBe("Relative filesystem browse paths require a current project.");
393 }),393 }),
394 );394 );
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 );
395 });410 });
396});411});
apps/server/src/workspace/Layers/WorkspaceEntries.ts+12-1
...@@ -457,7 +457,18 @@ export const makeWorkspaceEntries = Effect.gen(function* () {...@@ -457,7 +457,18 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
457 detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`,457 detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`,
458 cause,458 cause,
459 }),459 }),
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
462 const showHidden = endsWithSeparator || prefix.startsWith(".");473 const showHidden = endsWithSeparator || prefix.startsWith(".");
463 const lowerPrefix = prefix.toLowerCase();474 const lowerPrefix = prefix.toLowerCase();
apps/web/src/components/CommandPalette.tsx+6-20
...@@ -562,11 +562,7 @@ function OpenCommandPaletteDialog() {...@@ -562,11 +562,7 @@ function OpenCommandPaletteDialog() {
562 !relativePathNeedsActiveProject,562 !relativePathNeedsActiveProject,
563 });563 });
564 const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES;564 const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES;
565 const {565 const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo(
566 filteredEntries: filteredBrowseEntries,
567 highlightedEntry: highlightedBrowseEntry,
568 exactEntry: exactBrowseEntry,
569 } = useMemo(
570 () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }),566 () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }),
571 [browseEntries, browseFilterQuery, highlightedItemValue],567 [browseEntries, browseFilterQuery, highlightedItemValue],
572 );568 );
...@@ -587,27 +583,17 @@ function OpenCommandPaletteDialog() {...@@ -587,27 +583,17 @@ function OpenCommandPaletteDialog() {
587 [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient],583 [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient],
588 );584 );
589585
590 // Prefetch the parent and the most likely next child so browse navigation586 // Prefetch only the parent (for back-navigation). Prefetching the
591 // stays warm without scanning every child directory in large trees.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.
592 useEffect(() => {590 useEffect(() => {
593 if (!isBrowsing || filteredBrowseEntries.length === 0) return;591 if (!isBrowsing || filteredBrowseEntries.length === 0) return;
594592
595 if (canNavigateUp(query)) {593 if (canNavigateUp(query)) {
596 prefetchBrowsePath(getBrowseParentPath(query)!);594 prefetchBrowsePath(getBrowseParentPath(query)!);
597 }595 }
598596 }, [filteredBrowseEntries.length, isBrowsing, prefetchBrowsePath, query]);
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 ]);
611597
612 const openProjectFromSearch = useMemo(598 const openProjectFromSearch = useMemo(
613 () => async (project: (typeof projects)[number]) => {599 () => async (project: (typeof projects)[number]) => {