Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
29 changes: 29 additions & 0 deletions .github/workflows/release-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,35 @@ jobs:
toolchain: stable
targets: ${{ inputs.rust_target }}

- name: Cache Tailcat runtime
id: tailcat_cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: native/tailcat/dist/${{ inputs.resource_key }}
key: tailcat-${{ inputs.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: inputs.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: inputs.platform == 'mac' && steps.tailcat_cache.outputs.cache-hit != 'true'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # 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. The desktop app and the CLI
# archive both ship this copy.
- name: Fetch Tailcat runtime
shell: bash
run: node scripts/fetch-tailcat.ts --platform "${{ inputs.resource_key }}"${{ inputs.platform == 'mac' && ' --build-from-source' || '' }}

- name: Download relay client tracing config
if: inputs.relay_client_tracing
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ apps/mobile/.generated/
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 @@ -21,6 +21,7 @@
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
"@t3tools/ssh": "workspace:*",
"@t3tools/tailcat": "workspace:*",
"@t3tools/tailscale": "workspace:*",
"dbus-next": "0.10.2",
"effect": "catalog:",
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 @@ -536,6 +537,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 @@ -561,6 +563,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 @@ -889,7 +895,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 @@ -68,6 +68,7 @@ import {
setSnapShotShortcutSuppressed,
} from "./methods/snapShot.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 @@ -115,6 +116,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 @@ -68,6 +68,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.
*/

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);
}),
});

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);
}),
});

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);
}),
});

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));
}),
});

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 @@ -55,6 +55,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 @@ -139,6 +142,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 @@ -197,6 +208,7 @@ const desktopApplicationLayer = Layer.mergeAll(
DesktopLinuxUrlHandler.layer,
DesktopShellEnvironment.layer,
desktopSshLayer,
desktopTailcatLayer,
).pipe(
Layer.provideMerge(desktopSnapShotLayer),
Layer.provideMerge(DesktopUpdates.layer),
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 @@ -161,6 +161,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