| author | |
| committer | |
| log | b76f161d551235e1de89ed85c747f14041ac5d99 |
| tree | 45f42ac16e9b8c4f0cab5691e19ec4d6aee7ac66 |
| parent | 75257d64e9108b6e1620acbf423462fba3e0b62e |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 64 | ); |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | function dieOnSpawnLayer() { | |
| 68 | return Layer.succeed( | |
| 69 | ChildProcessSpawner.ChildProcessSpawner, | |
| 70 | ChildProcessSpawner.make(() => Effect.die("unexpected tailscale spawn")), | |
| 71 | ); | |
| 72 | } | |
| 73 | ||
| 67 | 74 | function makeEnvironmentLayer(baseDir: string, env: Record<string, string | undefined> = {}) { |
| 68 | 75 | return makeDesktopEnvironmentLayer({ |
| 69 | 76 | dirname: "/repo/apps/desktop/src", |
| ... | ... | @@ -86,6 +93,7 @@ function makeLayer(input: { |
| 86 | 93 | readonly baseDir: string; |
| 87 | 94 | readonly networkInterfaces?: DesktopNetworkInterfaces; |
| 88 | 95 | readonly env?: Record<string, string | undefined>; |
| 96 | readonly spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>; | |
| 89 | 97 | }) { |
| 90 | 98 | const env = { T3CODE_HOME: input.baseDir, ...input.env }; |
| 91 | 99 | const environmentLayer = makeEnvironmentLayer(input.baseDir, env); |
| ... | ... | @@ -97,7 +105,7 @@ function makeLayer(input: { |
| 97 | 105 | Layer.provideMerge(DesktopAppSettings.layer), |
| 98 | 106 | Layer.provideMerge(NodeFileSystem.layer), |
| 99 | 107 | Layer.provideMerge(NodeHttpClient.layerUndici), |
| 100 | Layer.provideMerge(mockSpawnerLayer()), | |
| 108 | Layer.provideMerge(input.spawnerLayer ?? mockSpawnerLayer()), | |
| 101 | 109 | Layer.provideMerge(networkLayer), |
| 102 | 110 | Layer.provideMerge(DesktopConfig.layerTest(env)), |
| 103 | 111 | Layer.provideMerge(environmentLayer), |
| ... | ... | @@ -116,13 +124,23 @@ const withHarness = <A, E, R>( |
| 116 | 124 | | DesktopAppSettings.DesktopAppSettings |
| 117 | 125 | >, |
| 118 | 126 | env: Record<string, string | undefined> = {}, |
| 127 | spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>, | |
| 119 | 128 | ) => |
| 120 | 129 | Effect.gen(function* () { |
| 121 | 130 | const fileSystem = yield* FileSystem.FileSystem; |
| 122 | 131 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 123 | 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 | 144 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); |
| 127 | 145 | |
| 128 | 146 | describe("DesktopServerExposure", () => { |
| ... | ... | @@ -239,6 +257,27 @@ describe("DesktopServerExposure", () => { |
| 239 | 257 | ), |
| 240 | 258 | ); |
| 241 | 259 | |
| 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 | 281 | it.effect("uses ConfigProvider desktop exposure overrides", () => |
| 243 | 282 | withHarness( |
| 244 | 283 | lanNetworkInterfaces, |
apps/desktop/src/backend/DesktopServerExposure.ts+25| ... | ... | @@ -12,6 +12,7 @@ import type { |
| 12 | 12 | } from "@t3tools/contracts"; |
| 13 | 13 | import * as Context from "effect/Context"; |
| 14 | 14 | import * as Data from "effect/Data"; |
| 15 | import * as Duration from "effect/Duration"; | |
| 15 | 16 | import * as Effect from "effect/Effect"; |
| 16 | 17 | import * as Layer from "effect/Layer"; |
| 17 | 18 | import * as Option from "effect/Option"; |
| ... | ... | @@ -22,8 +23,11 @@ import { ChildProcessSpawner } from "effect/unstable/process"; |
| 22 | 23 | import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts"; |
| 23 | 24 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 24 | 25 | import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; |
| 26 | import { readTailscaleStatus } from "@t3tools/tailscale"; | |
| 25 | 27 | import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts"; |
| 26 | 28 | |
| 29 | const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60); | |
| 30 | ||
| 27 | 31 | export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; |
| 28 | 32 | const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; |
| 29 | 33 | |
| ... | ... | @@ -412,6 +416,18 @@ const make = Effect.gen(function* () { |
| 412 | 416 | const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings; |
| 413 | 417 | const stateRef = yield* Ref.make(initialRuntimeState()); |
| 414 | 418 | |
| 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 | 431 | const readNetworkInterfaces = networkInterfaces.read; |
| 416 | 432 | |
| 417 | 433 | const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); |
| ... | ... | @@ -516,11 +532,20 @@ const make = Effect.gen(function* () { |
| 516 | 532 | exposure: toResolvedExposure(state), |
| 517 | 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 | 543 | const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ |
| 520 | 544 | port: state.port, |
| 521 | 545 | serveEnabled: state.tailscaleServeEnabled, |
| 522 | 546 | servePort: state.tailscaleServePort, |
| 523 | 547 | networkInterfaces: currentNetworkInterfaces, |
| 548 | readMagicDnsName: cachedReadMagicDnsName, | |
| 524 | 549 | }).pipe( |
| 525 | 550 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), |
| 526 | 551 | Effect.provideService(HttpClient.HttpClient, httpClient), |
apps/desktop/src/backend/tailscaleEndpointProvider.test.ts+19| ... | ... | @@ -104,6 +104,25 @@ describe("tailscale endpoint provider", () => { |
| 104 | 104 | }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), |
| 105 | 105 | ); |
| 106 | 106 | |
| 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 | 126 | it.effect( |
| 108 | 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 | 105 | readonly servePort?: number; |
| 106 | 106 | readonly networkInterfaces: DesktopNetworkInterfaces; |
| 107 | 107 | readonly statusJson?: string | null; |
| 108 | readonly readMagicDnsName?: Effect.Effect< | |
| 109 | string | null, | |
| 110 | never, | |
| 111 | ChildProcessSpawner.ChildProcessSpawner | |
| 112 | >; | |
| 108 | 113 | readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>; |
| 109 | 114 | }): Effect.fn.Return< |
| 110 | 115 | readonly AdvertisedEndpoint[], |
| ... | ... | @@ -112,12 +117,15 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd |
| 112 | 117 | ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient |
| 113 | 118 | > { |
| 114 | 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 | 126 | const dnsName = |
| 116 | 127 | input.statusJson === undefined |
| 117 | ? yield* readTailscaleStatus.pipe( | |
| 118 | Effect.map((status) => status.magicDnsName), | |
| 119 | Effect.orElseSucceed(() => null), | |
| 120 | ) | |
| 128 | ? yield* readDnsName | |
| 121 | 129 | : input.statusJson |
| 122 | 130 | ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( |
| 123 | 131 | Effect.orElseSucceed(() => null), |
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+15| ... | ... | @@ -392,5 +392,20 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { |
| 392 | 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 | 457 | detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, |
| 458 | 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 | ); | |
| 461 | 472 | |
| 462 | 473 | const showHidden = endsWithSeparator || prefix.startsWith("."); |
| 463 | 474 | const lowerPrefix = prefix.toLowerCase(); |
apps/web/src/components/CommandPalette.tsx+6-20| ... | ... | @@ -562,11 +562,7 @@ function OpenCommandPaletteDialog() { |
| 562 | 562 | !relativePathNeedsActiveProject, |
| 563 | 563 | }); |
| 564 | 564 | 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( | |
| 570 | 566 | () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), |
| 571 | 567 | [browseEntries, browseFilterQuery, highlightedItemValue], |
| 572 | 568 | ); |
| ... | ... | @@ -587,27 +583,17 @@ function OpenCommandPaletteDialog() { |
| 587 | 583 | [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient], |
| 588 | 584 | ); |
| 589 | 585 | |
| 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. | |
| 592 | 590 | useEffect(() => { |
| 593 | 591 | if (!isBrowsing || filteredBrowseEntries.length === 0) return; |
| 594 | 592 | |
| 595 | 593 | if (canNavigateUp(query)) { |
| 596 | 594 | prefetchBrowsePath(getBrowseParentPath(query)!); |
| 597 | 595 | } |
| 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]); | |
| 611 | 597 | |
| 612 | 598 | const openProjectFromSearch = useMemo( |
| 613 | 599 | () => async (project: (typeof projects)[number]) => { |