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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ jobs:
- name: Ensure Electron runtime is installed
run: vp run --filter @t3tools/desktop ensure:electron

# Schema and per-platform pin check only; no download. Release builds
# fetch the binaries this manifest describes.
- name: Verify Tailcat manifest
run: node scripts/fetch-tailcat.ts --verify --manifest-only

- name: Check
run: vp check

Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/desktop-macos-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,29 @@ jobs:
with:
targets: aarch64-apple-darwin

- name: Cache Tailcat runtime
id: tailcat_cache
uses: actions/cache@v6
with:
path: native/tailcat/dist/darwin-arm64
key: tailcat-darwin-arm64-${{ hashFiles('native/tailcat/manifest.json') }}

# No upstream macOS archive: compile the pinned tag with the manifest's Go.
- name: Resolve Tailcat Go version
if: steps.tailcat_cache.outputs.cache-hit != 'true'
id: tailcat_go
run: echo "version=$(node -p "require('./native/tailcat/manifest.json').source.goVersion")" >> "$GITHUB_OUTPUT"

- name: Setup Go
if: steps.tailcat_cache.outputs.cache-hit != 'true'
uses: actions/setup-go@v7
with:
go-version: ${{ steps.tailcat_go.outputs.version }}
cache: false

- name: Fetch Tailcat runtime
run: node scripts/fetch-tailcat.ts --platform darwin-arm64 --build-from-source

- id: version
name: Set preview version and public configuration
shell: bash
Expand Down
57 changes: 56 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,34 @@ jobs:
with:
targets: ${{ matrix.rust_target }}

- name: Cache Tailcat runtime
id: tailcat_cache
uses: actions/cache@v6
with:
path: native/tailcat/dist/${{ matrix.resource_key }}
key: tailcat-${{ matrix.resource_key }}-${{ hashFiles('native/tailcat/manifest.json') }}

# Upstream publishes no macOS Tailcat archive, so the macOS jobs compile
# the pinned tag with the Go toolchain the manifest names.
- name: Resolve Tailcat Go version
if: matrix.platform == 'mac' && steps.tailcat_cache.outputs.cache-hit != 'true'
id: tailcat_go
shell: bash
run: echo "version=$(node -p "require('./native/tailcat/manifest.json').source.goVersion")" >> "$GITHUB_OUTPUT"

- name: Setup Go
if: matrix.platform == 'mac' && steps.tailcat_cache.outputs.cache-hit != 'true'
uses: actions/setup-go@v7
with:
go-version: ${{ steps.tailcat_go.outputs.version }}
cache: false

# Verifies a cached runtime against the manifest and only downloads or
# builds when nothing valid is staged.
- name: Fetch Tailcat runtime
shell: bash
run: node scripts/fetch-tailcat.ts --platform "${{ matrix.resource_key }}"${{ matrix.platform == 'mac' && ' --build-from-source' || '' }}

- name: Download relay client tracing config
uses: actions/download-artifact@v8
with:
Expand Down Expand Up @@ -806,8 +834,35 @@ jobs:
- name: Align package versions to release version
run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"

- name: Cache Tailcat runtimes
id: tailcat_cache
uses: actions/cache@v6
with:
path: native/tailcat/dist
key: tailcat-all-${{ hashFiles('native/tailcat/manifest.json') }}

- name: Resolve Tailcat Go version
if: steps.tailcat_cache.outputs.cache-hit != 'true'
id: tailcat_go
run: echo "version=$(node -p "require('./native/tailcat/manifest.json').source.goVersion")" >> "$GITHUB_OUTPUT"

# The npm package ships every platform's Tailcat runtime. Linux and
# Windows come from the pinned upstream archives; both darwin binaries are
# cross-compiled here (CGO_ENABLED=0, -trimpath), which yields the same
# bytes as the native build on the macOS runners.
- name: Setup Go
if: steps.tailcat_cache.outputs.cache-hit != 'true'
uses: actions/setup-go@v7
with:
go-version: ${{ steps.tailcat_go.outputs.version }}
cache: false

- name: Fetch Tailcat runtimes
run: node scripts/fetch-tailcat.ts --all

# The t3 build task depends on @t3tools/web#build, so the web client is
# built (once) as part of this step.
# built (once) as part of this step. The publish step below stages the
# fetched Tailcat runtimes into dist/tailcat/<platform-key>/.
- name: Build CLI package
run: vp run --filter t3 build

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ apps/mobile/.showcase/
artifacts/app-store/screenshots/
.github/pr-assets/
native/**/target/
native/tailcat/dist/
apps/desktop/prod-resources/tailcat/
node_modules/
.alchemy/
*.log
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
"@t3tools/ssh": "workspace:*",
"@t3tools/tailcat": "workspace:*",
"@t3tools/tailscale": "workspace:*",
"effect": "catalog:",
"electron": "43.4.1",
Expand Down
18 changes: 17 additions & 1 deletion apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import serverPackageJson from "../../../server/package.json" with { type: "json"

import * as DesktopBackendManager from "./DesktopBackendManager.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import { resolveDesktopTailcatBinaryPath } from "../tailcat/DesktopTailcatRuntime.ts";
import * as DesktopServerExposure from "./DesktopServerExposure.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
Expand Down Expand Up @@ -471,6 +472,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
function* (
input: SharedBootstrapInput & {
readonly resourceMonitorPath: Option.Option<string>;
readonly tailcatBinaryPath: Option.Option<string>;
},
): Effect.fn.Return<
DesktopBackendManager.DesktopBackendStartConfig,
Expand All @@ -496,6 +498,10 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
onNone: () => ({}),
onSome: (resourceMonitorPath) => ({ resourceMonitorPath }),
}),
...Option.match(input.tailcatBinaryPath, {
onNone: () => ({}),
onSome: (tailcatBinaryPath) => ({ tailcatBinaryPath }),
}),
...buildObservabilityFragment(input.observabilitySettings),
};

Expand Down Expand Up @@ -809,7 +815,17 @@ export const make = Effect.gen(function* () {
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
);
return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath }).pipe(
// The bundled Tailcat binary is shared with the backend so the server's
// remote access and the desktop's forwards run the same pinned build.
const tailcatBinaryPath = yield* resolveDesktopTailcatBinaryPath().pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
);
return yield* resolvePrimaryStartConfig({
...shared,
resourceMonitorPath,
tailcatBinaryPath,
}).pipe(
Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure),
);
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
showContextMenu,
} from "./methods/window.ts";
import * as PreviewIpc from "./methods/preview.ts";
import * as TailcatIpc from "./methods/tailcatEnvironment.ts";
import * as AppActivationIpc from "./methods/appActivation.ts";
import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts";

Expand Down Expand Up @@ -78,6 +79,10 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(issueSshWebSocketTicket);
yield* ipc.handle(resolveSshPasswordPrompt);

for (const tailcatMethod of TailcatIpc.methods) {
yield* ipc.handle(tailcatMethod);
}

yield* ipc.handle(getServerExposureState);
yield* ipc.handle(setServerExposureMode);
yield* ipc.handle(setTailscaleServeEnabled);
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled"
export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro";
export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only";
export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
export const ENSURE_TAILCAT_ENVIRONMENT_CHANNEL = "desktop:ensure-tailcat-environment";
export const RESTART_TAILCAT_ENVIRONMENT_CHANNEL = "desktop:restart-tailcat-environment";
export const DISCONNECT_TAILCAT_ENVIRONMENT_CHANNEL = "desktop:disconnect-tailcat-environment";
export const GET_TAILCAT_CONNECTION_DIAGNOSTICS_CHANNEL =
"desktop:get-tailcat-connection-diagnostics";
export const PROBE_TAILCAT_CONNECTION_PATH_CHANNEL = "desktop:probe-tailcat-connection-path";
export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab";
export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab";
export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview";
Expand Down
81 changes: 81 additions & 0 deletions apps/desktop/src/ipc/methods/tailcatEnvironment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
DesktopTailcatConnectionIdInputSchema,
DesktopTailcatEnvironmentBootstrapSchema,
DesktopTailcatEnvironmentEnsureInputSchema,
TailcatConnectionDiagnostics,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

import * as IpcChannels from "../channels.ts";
import * as DesktopIpc from "../DesktopIpc.ts";
import * as DesktopTailcatEnvironment from "../../tailcat/DesktopTailcatEnvironment.ts";

/**
* Renderer-facing Tailcat transport methods. The renderer never touches the
* private key or the child process; it receives a loopback endpoint and
* diagnostics, and asks for lifecycle changes by connection id.
*/

export const ensureTailcatEnvironment = DesktopIpc.makeIpcMethod({
channel: IpcChannels.ENSURE_TAILCAT_ENVIRONMENT_CHANNEL,
payload: DesktopTailcatEnvironmentEnsureInputSchema,
result: DesktopTailcatEnvironmentBootstrapSchema,
handler: Effect.fn("desktop.ipc.tailcatEnvironment.ensureEnvironment")(function* (input) {
const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment;
return yield* tailcat.ensureEnvironment(input);
}),
});

export const restartTailcatEnvironment = DesktopIpc.makeIpcMethod({
channel: IpcChannels.RESTART_TAILCAT_ENVIRONMENT_CHANNEL,
payload: DesktopTailcatConnectionIdInputSchema,
result: DesktopTailcatEnvironmentBootstrapSchema,
handler: Effect.fn("desktop.ipc.tailcatEnvironment.restartEnvironment")(function* ({
connectionId,
}) {
const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment;
return yield* tailcat.restartEnvironment(connectionId);
}),
});

export const disconnectTailcatEnvironment = DesktopIpc.makeIpcMethod({
channel: IpcChannels.DISCONNECT_TAILCAT_ENVIRONMENT_CHANNEL,
payload: DesktopTailcatConnectionIdInputSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.tailcatEnvironment.disconnectEnvironment")(function* ({
connectionId,
}) {
const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment;
yield* tailcat.disconnectEnvironment(connectionId);
}),
});

export const getTailcatConnectionDiagnostics = DesktopIpc.makeIpcMethod({
channel: IpcChannels.GET_TAILCAT_CONNECTION_DIAGNOSTICS_CHANNEL,
payload: DesktopTailcatConnectionIdInputSchema,
result: Schema.NullOr(TailcatConnectionDiagnostics),
handler: Effect.fn("desktop.ipc.tailcatEnvironment.diagnostics")(function* ({ connectionId }) {
const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment;
return Option.getOrNull(yield* tailcat.diagnostics(connectionId));
}),
});

export const probeTailcatConnectionPath = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PROBE_TAILCAT_CONNECTION_PATH_CHANNEL,
payload: DesktopTailcatConnectionIdInputSchema,
result: Schema.NullOr(TailcatConnectionDiagnostics),
handler: Effect.fn("desktop.ipc.tailcatEnvironment.probePath")(function* ({ connectionId }) {
const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment;
return Option.getOrNull(yield* tailcat.probePath(connectionId));
}),
});

export const methods = [
ensureTailcatEnvironment,
restartTailcatEnvironment,
disconnectTailcatEnvironment,
getTailcatConnectionDiagnostics,
probeTailcatConnectionPath,
] as const;
12 changes: 12 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts";
import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts";
import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";
import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
import * as DesktopTailcatEnvironment from "./tailcat/DesktopTailcatEnvironment.ts";
import * as DesktopTailcatIdentity from "./tailcat/DesktopTailcatIdentity.ts";
import * as DesktopTailcatRuntime from "./tailcat/DesktopTailcatRuntime.ts";
import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
import * as DesktopState from "./app/DesktopState.ts";
import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
Expand Down Expand Up @@ -145,6 +148,14 @@ const desktopSshLayer = desktopSshEnvironmentLayer.pipe(
Layer.provideMerge(DesktopSshPasswordPrompts.layer()),
);

// Tailcat forwards for saved Tailcat environments, plus this device's client
// identity (encrypted with safeStorage). Rides on the foundation for paths.
const desktopTailcatLayer = DesktopTailcatEnvironment.layer.pipe(
Layer.provideMerge(DesktopTailcatIdentity.layer),
Layer.provideMerge(DesktopTailcatRuntime.layer),
Layer.provide(desktopFoundationLayer),
);

const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
Layer.provideMerge(DesktopNetworkInterfaces.layer),
Layer.provideMerge(desktopFoundationLayer),
Expand Down Expand Up @@ -199,6 +210,7 @@ const desktopApplicationLayer = Layer.mergeAll(
DesktopLinuxUrlHandler.layer,
DesktopShellEnvironment.layer,
desktopSshLayer,
desktopTailcatLayer,
).pipe(
Layer.provideMerge(DesktopUpdates.layer),
Layer.provideMerge(desktopWslBackendLayer),
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ contextBridge.exposeInMainWorld("desktopBridge", {
},
resolveSshPasswordPrompt: (requestId, password) =>
ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }),
ensureTailcatEnvironment: (input) =>
ipcRenderer.invoke(IpcChannels.ENSURE_TAILCAT_ENVIRONMENT_CHANNEL, input),
restartTailcatEnvironment: (connectionId) =>
ipcRenderer.invoke(IpcChannels.RESTART_TAILCAT_ENVIRONMENT_CHANNEL, { connectionId }),
disconnectTailcatEnvironment: (connectionId) =>
ipcRenderer.invoke(IpcChannels.DISCONNECT_TAILCAT_ENVIRONMENT_CHANNEL, { connectionId }),
getTailcatConnectionDiagnostics: (connectionId) =>
ipcRenderer.invoke(IpcChannels.GET_TAILCAT_CONNECTION_DIAGNOSTICS_CHANNEL, { connectionId }),
probeTailcatConnectionPath: (connectionId) =>
ipcRenderer.invoke(IpcChannels.PROBE_TAILCAT_CONNECTION_PATH_CHANNEL, { connectionId }),
getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL),
setServerExposureMode: (mode) =>
ipcRenderer.invoke(IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),
Expand Down
Loading
Loading