Skip to content
Closed
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
24015eb
feat(contracts): add Tailcat transport and federation protocol contracts
BearHuddleston Sep 4, 2026
28980c0
feat(tailcat): add the pinned Tailcat runtime package and manifest
BearHuddleston Sep 4, 2026
03949fb
feat(client-runtime): add the Tailcat connection target and gateway
BearHuddleston Sep 4, 2026
d823723
feat(server): Tailcat remote access and server-to-server federation
BearHuddleston Sep 4, 2026
d6e0335
feat(cli): add t3 remote tailcat and t3 peer commands
BearHuddleston Sep 4, 2026
71bd32e
feat(desktop): manage Tailcat forwards and the client identity in the…
BearHuddleston Sep 4, 2026
d947ae7
feat(web,mobile): Tailcat remote access, Add environment, and federat…
BearHuddleston Sep 4, 2026
6326cd7
build(tailcat): fetch, verify, and stage the pinned Tailcat binary
BearHuddleston Sep 4, 2026
b8c03e6
docs: Tailcat transport, federation protocol, and ADR
BearHuddleston Sep 4, 2026
a2b1496
build(cli): stage the pinned Tailcat binaries into the published CLI …
BearHuddleston Sep 4, 2026
a8394e9
refactor(tailcat): apply review cleanups across transport, federation…
BearHuddleston Sep 4, 2026
a162dcf
fix(federation): address review findings on locks, idle sweep, and pe…
BearHuddleston Sep 4, 2026
3a76ef8
Merge origin/main into t3code/add-tailcat-networking
BearHuddleston Sep 6, 2026
66c7707
Merge branch 'main' into t3code/add-tailcat-networking
BearHuddleston Sep 7, 2026
217e0f5
refactor(server): simplify Tailcat and federation state handling
BearHuddleston Sep 7, 2026
2978edb
fix(tailcat): preserve trust and clean up failed pairings
BearHuddleston Sep 7, 2026
ca431c3
Merge branch 'main' into t3code/add-tailcat-networking
BearHuddleston Sep 8, 2026
d671cc9
Merge branch 'main' into t3code/add-tailcat-networking
BearHuddleston Sep 26, 2026
6c31ca7
refactor: keep Tailcat and federation helpers module-private
BearHuddleston Sep 26, 2026
9ef3e8a
refactor: simplify Tailcat and federation plumbing
BearHuddleston Sep 26, 2026
eda45b0
refactor: remove server-to-server federation
BearHuddleston Sep 26, 2026
a239678
fix: headless Tailcat output points to the desktop app, not mobile
BearHuddleston Sep 26, 2026
90d0e6c
fix(web): Tailcat details and connection codes show current state
BearHuddleston Sep 26, 2026
b4dbd19
refactor(tailcat): listener admits any Tailcat node; T3 auth gates ac…
BearHuddleston Sep 26, 2026
c6cafec
refactor(tailcat): connection codes are pasted, not scanned
BearHuddleston Sep 26, 2026
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ 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
# Files/dependencies are repo-wide; export checks cover clean workspaces only.
- name: Check unused code
run: vp run knip: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 @@ -493,6 +493,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 @@ -816,8 +844,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 @@ -47,6 +47,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 @@ -79,6 +80,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 @@ -48,6 +48,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