Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions apps/desktop/src/backend/DesktopServerExposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> = {}) {
return makeDesktopEnvironmentLayer({
dirname: "/repo/apps/desktop/src",
Expand All @@ -86,6 +93,7 @@ function makeLayer(input: {
readonly baseDir: string;
readonly networkInterfaces?: DesktopNetworkInterfaces;
readonly env?: Record<string, string | undefined>;
readonly spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
}) {
const env = { T3CODE_HOME: input.baseDir, ...input.env };
const environmentLayer = makeEnvironmentLayer(input.baseDir, env);
Expand All @@ -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),
Expand All @@ -116,13 +124,23 @@ const withHarness = <A, E, R>(
| DesktopAppSettings.DesktopAppSettings
>,
env: Record<string, string | undefined> = {},
spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>,
) =>
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", () => {
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/backend/DesktopServerExposure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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<string | null>(null)),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
),
TAILSCALE_STATUS_CACHE_TTL,
);

const readNetworkInterfaces = networkInterfaces.read;

const getState = Ref.get(stateRef).pipe(Effect.map(toContractState));
Expand Down Expand Up @@ -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),
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/backend/tailscaleEndpointProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
() =>
Expand Down
16 changes: 12 additions & 4 deletions apps/desktop/src/backend/tailscaleEndpointProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,27 @@ 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<boolean, never, HttpClient.HttpClient>;
}): Effect.fn.Return<
readonly AdvertisedEndpoint[],
never,
ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient
> {
const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input);
const readDnsName =
input.readMagicDnsName ??
readTailscaleStatus.pipe(
Effect.map((status) => status.magicDnsName),
Effect.catch(() => Effect.succeed<string | null>(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)),
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/workspace/Layers/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] });
}),
);
});
});
13 changes: 12 additions & 1 deletion apps/server/src/workspace/Layers/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Dirent[]>([]),
),
);

const showHidden = endsWithSeparator || prefix.startsWith(".");
const lowerPrefix = prefix.toLowerCase();
Expand Down
26 changes: 6 additions & 20 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
Expand All @@ -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]) => {
Expand Down
Loading