Skip to content

Commit b76f161

Browse files
fix(desktop): stop looping macOS TCC permission prompts (pingdotgg#2745)
Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: Julius Marminge <jmarminge@gmail.com>
1 parent 75257d6 commit b76f161

7 files changed

Lines changed: 130 additions & 27 deletions

File tree

apps/desktop/src/backend/DesktopServerExposure.test.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ function mockSpawnerLayer(statusJson = "{}") {
6464
);
6565
}
6666

67+
function dieOnSpawnLayer() {
68+
return Layer.succeed(
69+
ChildProcessSpawner.ChildProcessSpawner,
70+
ChildProcessSpawner.make(() => Effect.die("unexpected tailscale spawn")),
71+
);
72+
}
73+
6774
function 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

128146
describe("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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
} from "@t3tools/contracts";
1313
import * as Context from "effect/Context";
1414
import * as Data from "effect/Data";
15+
import * as Duration from "effect/Duration";
1516
import * as Effect from "effect/Effect";
1617
import * as Layer from "effect/Layer";
1718
import * as Option from "effect/Option";
@@ -22,8 +23,11 @@ import { ChildProcessSpawner } from "effect/unstable/process";
2223
import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts";
2324
import * as DesktopConfig from "../app/DesktopConfig.ts";
2425
import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
26+
import { readTailscaleStatus } from "@t3tools/tailscale";
2527
import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts";
2628

29+
const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60);
30+
2731
export const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
2832
const 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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,19 +105,27 @@ 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[],
111116
never,
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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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]) => {

0 commit comments

Comments
 (0)