diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 0f3e9eaeb45..e5fbb84c8ad 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -64,6 +64,13 @@ function mockSpawnerLayer(statusJson = "{}") { ); } +function dieOnSpawnLayer() { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected tailscale spawn")), + ); +} + function makeEnvironmentLayer(baseDir: string, env: Record = {}) { return makeDesktopEnvironmentLayer({ dirname: "/repo/apps/desktop/src", @@ -86,6 +93,7 @@ function makeLayer(input: { readonly baseDir: string; readonly networkInterfaces?: DesktopNetworkInterfaces; readonly env?: Record; + readonly spawnerLayer?: Layer.Layer; }) { const env = { T3CODE_HOME: input.baseDir, ...input.env }; const environmentLayer = makeEnvironmentLayer(input.baseDir, env); @@ -97,7 +105,7 @@ function makeLayer(input: { Layer.provideMerge(DesktopAppSettings.layer), Layer.provideMerge(NodeFileSystem.layer), Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(mockSpawnerLayer()), + Layer.provideMerge(input.spawnerLayer ?? mockSpawnerLayer()), Layer.provideMerge(networkLayer), Layer.provideMerge(DesktopConfig.layerTest(env)), Layer.provideMerge(environmentLayer), @@ -116,13 +124,23 @@ const withHarness = ( | DesktopAppSettings.DesktopAppSettings >, env: Record = {}, + spawnerLayer?: Layer.Layer, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-server-exposure-test-", }); - return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces, env }))); + return yield* effect.pipe( + Effect.provide( + makeLayer({ + baseDir, + networkInterfaces, + env, + ...(spawnerLayer ? { spawnerLayer } : {}), + }), + ), + ); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopServerExposure", () => { @@ -239,6 +257,27 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("does not spawn the tailscale CLI while server exposure is local-only", () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + // mode stays at default "local-only", tailscaleServeEnabled stays false. + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + // Only the loopback endpoint; no tailscale spawn means the dieOnSpawnLayer + // would have crashed the test if the gate was missing. + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/"], + ); + }), + {}, + dieOnSpawnLayer(), + ), + ); + it.effect("uses ConfigProvider desktop exposure overrides", () => withHarness( lanNetworkInterfaces, diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index bd0939a7775..4ec5b9cbf35 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -12,6 +12,7 @@ import type { } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -22,8 +23,11 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; +import { readTailscaleStatus } from "@t3tools/tailscale"; import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts"; +const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60); + export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; @@ -412,6 +416,18 @@ const make = Effect.gen(function* () { const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings; const stateRef = yield* Ref.make(initialRuntimeState()); + // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App + // Store Tailscale CLI lives inside Tailscale's sandbox container, so each + // spawn re-triggers the "Other apps" TCC prompt. + const cachedReadMagicDnsName = yield* Effect.cachedWithTTL( + readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.catch(() => Effect.succeed(null)), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ), + TAILSCALE_STATUS_CACHE_TTL, + ); + const readNetworkInterfaces = networkInterfaces.read; const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); @@ -516,11 +532,20 @@ const make = Effect.gen(function* () { exposure: toResolvedExposure(state), customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, }); + + // Don't spawn the Tailscale CLI when the user hasn't opted into any + // network exposure. The spawn itself triggers a macOS "Other apps" + // TCC prompt on Mac App Store Tailscale builds. + if (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) { + return coreEndpoints; + } + const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: state.port, serveEnabled: state.tailscaleServeEnabled, servePort: state.tailscaleServePort, networkInterfaces: currentNetworkInterfaces, + readMagicDnsName: cachedReadMagicDnsName, }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index 612ef3bd73f..28bf211f09a 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -104,6 +104,25 @@ describe("tailscale endpoint provider", () => { }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), ); + it.effect("uses an injected magic DNS name reader instead of spawning tailscale", () => + Effect.gen(function* () { + let readerCalls = 0; + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: {}, + readMagicDnsName: Effect.sync(() => { + readerCalls += 1; + return "desktop.tail.ts.net"; + }), + }); + assert.equal(readerCalls, 1); + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["https://desktop.tail.ts.net/"], + ); + }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), + ); + it.effect( "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", () => diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index bd46e9f03f5..76c3b5b2298 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -105,6 +105,11 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd readonly servePort?: number; readonly networkInterfaces: DesktopNetworkInterfaces; readonly statusJson?: string | null; + readonly readMagicDnsName?: Effect.Effect< + string | null, + never, + ChildProcessSpawner.ChildProcessSpawner + >; readonly probe?: (baseUrl: string) => Effect.Effect; }): Effect.fn.Return< readonly AdvertisedEndpoint[], @@ -112,12 +117,15 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient > { const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); + const readDnsName = + input.readMagicDnsName ?? + readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.catch(() => Effect.succeed(null)), + ); const dnsName = input.statusJson === undefined - ? yield* readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), - Effect.catch(() => Effect.succeed(null)), - ) + ? yield* readDnsName : input.statusJson ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( Effect.catch(() => Effect.succeed(null)), diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 84ea5c51937..ffee4d56a52 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -392,5 +392,20 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { expect(error.detail).toBe("Relative filesystem browse paths require a current project."); }), ); + + it.effect("returns an empty listing when the OS denies directory access", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-eacces-" }); + + const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); + vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(denied); + + const result = yield* workspaceEntries.browse({ + partialPath: appendSeparator(cwd), + }); + expect(result).toEqual({ parentPath: cwd, entries: [] }); + }), + ); }); }); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 0c0ab638207..28a24544355 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -452,7 +452,18 @@ export const makeWorkspaceEntries = Effect.gen(function* () { detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, cause, }), - }); + }).pipe( + // The user can deny macOS TCC prompts for the target dir (Documents, + // Downloads, Music, etc.); surface an empty listing instead of an + // error so the caller doesn't retry-loop the prompt. + Effect.catchIf( + (error) => { + const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; + return code === "EACCES" || code === "EPERM"; + }, + () => Effect.succeed([]), + ), + ); const showHidden = endsWithSeparator || prefix.startsWith("."); const lowerPrefix = prefix.toLowerCase(); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 7862191d40b..5627d93e3e2 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -562,11 +562,7 @@ function OpenCommandPaletteDialog() { !relativePathNeedsActiveProject, }); const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; - const { - filteredEntries: filteredBrowseEntries, - highlightedEntry: highlightedBrowseEntry, - exactEntry: exactBrowseEntry, - } = useMemo( + const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), [browseEntries, browseFilterQuery, highlightedItemValue], ); @@ -587,27 +583,17 @@ function OpenCommandPaletteDialog() { [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient], ); - // Prefetch the parent and the most likely next child so browse navigation - // stays warm without scanning every child directory in large trees. + // Prefetch only the parent (for back-navigation). Prefetching the + // highlighted child on every arrow-key press triggers a macOS TCC prompt + // whenever the highlighted entry is a permission-gated home dir (Music, + // Documents, Downloads, Desktop, etc.), so we wait for explicit navigation. useEffect(() => { if (!isBrowsing || filteredBrowseEntries.length === 0) return; if (canNavigateUp(query)) { prefetchBrowsePath(getBrowseParentPath(query)!); } - - const nextChild = highlightedBrowseEntry ?? exactBrowseEntry; - if (nextChild) { - prefetchBrowsePath(appendBrowsePathSegment(query, nextChild.name)); - } - }, [ - exactBrowseEntry, - filteredBrowseEntries.length, - highlightedBrowseEntry, - isBrowsing, - prefetchBrowsePath, - query, - ]); + }, [filteredBrowseEntries.length, isBrowsing, prefetchBrowsePath, query]); const openProjectFromSearch = useMemo( () => async (project: (typeof projects)[number]) => {