From 9cc290c80a6b9e4b3dab5c5eb0943cfb14582c1f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 07:21:42 +0000 Subject: [PATCH 1/6] test(client-runtime): reproduce #27398 pairing code spent when the connection cannot be saved Pairing redeems the one-time code via /oauth/token before the local connection store writes. When that write fails (desktop secure storage unavailable), the code is already spent and the same link returns 401. This test fails on main because /oauth/token is called before the failing save. Co-Authored-By: Claude Opus 5.5 --- .../src/connection/onboarding.test.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 2131507e547f7..43c40e379cff7 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -160,9 +160,12 @@ function layerPairingHttp( } function layerPairingOnboarding( - options?: Parameters[1], + options?: Parameters[1] & { + readonly saveError?: Persistence.ConnectionPersistenceError; + }, calls: Array<{ readonly url: string; readonly init: RequestInit }> = [], ) { + const saveError = options?.saveError; const dependencies = Layer.mergeAll( layerClientPresentation, layerPairingHttp(calls, options), @@ -171,7 +174,7 @@ function layerPairingOnboarding( listDisabled: Effect.succeed([]), }), Layer.mock(Persistence.ConnectionRegistrationStore)({ - register: () => Effect.void, + register: () => (saveError === undefined ? Effect.void : Effect.fail(saveError)), setEnabled: () => Effect.void, }), Layer.mock(Persistence.EnvironmentCacheStore)({}), @@ -482,6 +485,25 @@ describe("connection onboarding", () => { }).pipe(Effect.provide(layerPairingOnboarding({ failExchange: true }))), ); + it.effect("does not consume a pairing credential when the connection cannot be saved", () => { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const saveError = new Persistence.ConnectionPersistenceError({ + operation: "register-connection", + message: + "Could not register connection: Desktop secure storage is unavailable in this system context.", + }); + return Effect.gen(function* () { + const onboarding = yield* Onboarding.ConnectionOnboarding; + + const error = yield* onboarding.registerPairing(PAIRING_INPUT).pipe(Effect.flip); + + expect(error).toEqual(saveError); + expect(calls.map((call) => call.url)).toEqual([ + "https://remote.example.test/.well-known/t3/environment", + ]); + }).pipe(Effect.provide(layerPairingOnboarding({ saveError }, calls))); + }); + it.effect("rejects an incompatible server without consuming the pairing credential", () => Effect.gen(function* () { const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; From d1b0cc998cd5783496225b7d4577a10f988aca8a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 07:27:05 +0000 Subject: [PATCH 2/6] fix(client-runtime): keep pairing codes unspent when connections cannot be saved On a Linux desktop without an unlocked Secret Service keyring, the desktop catalog cannot be encrypted, so saving a new connection fails. Pairing redeemed the one-time code before trying that save, so every attempt spent the code. ConnectionRegistrationStore now exposes assertWritable, which pairing checks before redeeming the code. The web store implements it by rewriting the catalog unchanged through the same write path, so a desktop without encryption fails early; mobile succeeds trivially. The desktop save error now names the missing keyring and how to fix it, and the install guide notes the Linux keyring requirement. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/connection/storage.ts | 1 + apps/web/src/connection/storage.test.ts | 21 +++++++++++++++++++ apps/web/src/connection/storage.ts | 5 ++++- docs/user/install.md | 3 +++ .../src/connection/onboarding.test.ts | 1 + .../src/connection/onboarding.ts | 4 ++++ .../src/connection/registry.test.ts | 1 + .../src/platform/persistence.ts | 4 ++++ 8 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 83805e11942b1..9039a6901194a 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -59,6 +59,7 @@ export const layer = Layer.effectContext( ), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ + assertWritable: Effect.void, register: (registration, routes) => catalog .update((document) => registerConnectionInCatalog(document, registration, routes)) diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 7afe2b1e8573f..fd378115d1297 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -96,6 +96,27 @@ describe("ConnectionStorage.makeCatalogBackend", () => { ); }); +describe("ConnectionStorage registration writability", () => { + it.effect("fails when the desktop cannot encrypt the catalog, without changing it", () => + Effect.gen(function* () { + const saved = encodeCatalog(emptyCatalog); + const setConnectionCatalog = vi.fn(() => Promise.resolve(false)); + vi.stubGlobal("window", { + desktopBridge: { getConnectionCatalog: () => Promise.resolve(saved), setConnectionCatalog }, + }); + + const error = yield* Effect.gen(function* () { + const registrations = yield* Persistence.ConnectionRegistrationStore; + return yield* Effect.flip(registrations.assertWritable); + }).pipe(Effect.provide(ConnectionStorage.layer)); + + expect(error.operation).toBe("register-connection"); + expect(error.message).toContain("Secret Service keyring"); + expect(setConnectionCatalog).toHaveBeenCalledWith(saved); + }), + ); +}); + describe("IndexedDB connection recovery", () => { it.effect("reopens after a forced close and finalizes the current connection", () => Effect.gen(function* () { diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 7d9db4afdc494..2d7b026cf8e2b 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -404,7 +404,7 @@ export function makeCatalogBackend(database: DatabaseHandle): CatalogBackend { : Effect.fail( catalogError( "save", - "Desktop secure storage is unavailable in this system context.", + "T3 Code could not encrypt saved connections because no system keyring is available or unlocked. On Linux, start and unlock a Secret Service keyring such as GNOME Keyring or KWallet, then restart T3 Code.", ), ), ), @@ -636,6 +636,9 @@ export const layer = Layer.effectContext( ), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ + assertWritable: catalog + .update((document) => document) + .pipe(Effect.mapError((cause) => persistenceError("register-connection", cause))), register: Effect.fn("Persistence.ConnectionRegistrationStore.register")( function (registration, routes) { return catalog diff --git a/docs/user/install.md b/docs/user/install.md index 1e1693d0b32ee..4a956113d0c50 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -78,6 +78,9 @@ The `.deb` updates itself like the other desktop builds. It asks for your password to install each update. If your desktop has no password prompt, the update fails. Download the new `.deb` and install it the same way. +On Linux, the desktop app needs an unlocked Secret Service keyring, such as +GNOME Keyring or KWallet, to save connections to other machines. + ### Nix (nixpkgs) T3 Code is maintained in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/nixos-unstable/pkgs/by-name/t3/t3code). diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 43c40e379cff7..a9296ba65b99d 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -174,6 +174,7 @@ function layerPairingOnboarding( listDisabled: Effect.succeed([]), }), Layer.mock(Persistence.ConnectionRegistrationStore)({ + assertWritable: saveError === undefined ? Effect.void : Effect.fail(saveError), register: () => (saveError === undefined ? Effect.void : Effect.fail(saveError)), setEnabled: () => Effect.void, }), diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 48e8b614c67c8..4b11dfdf58597 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -138,6 +138,8 @@ export const preparePairingRegistration = Effect.fn( if (compatibilityError !== null && compatibilityError.serverUpdateRequired !== true) { return yield* compatibilityError; } + // A code redeemed for a connection that cannot be saved is spent for nothing. + yield* (yield* Persistence.ConnectionRegistrationStore).assertWritable; const access = yield* bootstrapRemoteBearerSession({ httpBaseUrl: target.httpBaseUrl, credential: target.credential, @@ -304,6 +306,7 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const ssh = yield* ClientCapabilities.SshEnvironmentGateway; const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; + const registrations = yield* Persistence.ConnectionRegistrationStore; // Provisioning retains the delegated onboarding operation spans. return ConnectionOnboarding.of({ @@ -314,6 +317,7 @@ export const make = Effect.gen(function* () { Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), Effect.provideService(ClientCapabilities.ClientPresentation, presentation), Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(Persistence.ConnectionRegistrationStore, registrations), ), registerSsh: Effect.fnUntraced( function* (input) { diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index ec8863949cef7..831627182979d 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -206,6 +206,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( listDisabled: Ref.get(storedDisabled).pipe(Effect.map((ids) => [...ids])), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ + assertWritable: Effect.void, register: (registration, routes) => Effect.gen(function* () { yield* options?.beforeRegistrationRegister?.(registration) ?? Effect.void; diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index e043279d3e576..c27338c0a2422 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -53,6 +53,10 @@ export class ConnectionTargetStore extends Context.Service< export class ConnectionRegistrationStore extends Context.Service< ConnectionRegistrationStore, { + /** + * Fails, without changing what is stored, when `register` could not save. + */ + readonly assertWritable: Effect.Effect; /** * Records of routes missing from `routes` are dropped. */ From a8a63143a53b574424bc918eb37a8dc3db1819ff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 07:44:50 +0000 Subject: [PATCH 3/6] fix(web): check desktop catalog writability without overwriting newer saves The pre-redeem check wrote this runtime's cached catalog back, so a failed pairing could erase a connection another window had saved since. The desktop backend now probes by rewriting the catalog it reads fresh from the bridge; IndexedDB, which never refuses saves for this reason, writes nothing. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/connection/storage.test.ts | 56 +++++++++++++++-- apps/web/src/connection/storage.ts | 61 ++++++++++++------- .../src/connection/onboarding.test.ts | 2 +- .../src/platform/persistence.ts | 3 +- 4 files changed, 93 insertions(+), 29 deletions(-) diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index fd378115d1297..643cde116c299 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -1,12 +1,18 @@ import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionRegistration, + BearerConnectionTarget, ConnectionTransientError, PrimaryConnectionTarget, } from "@t3tools/client-runtime/connection"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { ConnectionCatalogDocument, Persistence } from "@t3tools/client-runtime/platform"; import { describe, expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -97,12 +103,12 @@ describe("ConnectionStorage.makeCatalogBackend", () => { }); describe("ConnectionStorage registration writability", () => { - it.effect("fails when the desktop cannot encrypt the catalog, without changing it", () => + it.effect("fails when the desktop cannot encrypt the catalog", () => Effect.gen(function* () { - const saved = encodeCatalog(emptyCatalog); + // Without a keyring the desktop reads no catalog and refuses every save. const setConnectionCatalog = vi.fn(() => Promise.resolve(false)); vi.stubGlobal("window", { - desktopBridge: { getConnectionCatalog: () => Promise.resolve(saved), setConnectionCatalog }, + desktopBridge: { getConnectionCatalog: () => Promise.resolve(null), setConnectionCatalog }, }); const error = yield* Effect.gen(function* () { @@ -112,9 +118,51 @@ describe("ConnectionStorage registration writability", () => { expect(error.operation).toBe("register-connection"); expect(error.message).toContain("Secret Service keyring"); - expect(setConnectionCatalog).toHaveBeenCalledWith(saved); }), ); + + it.effect("keeps a connection another window saved after this window loaded its catalog", () => + Effect.gen(function* () { + let saved: string | null = null; + vi.stubGlobal("window", { + desktopBridge: { + getConnectionCatalog: () => Promise.resolve(saved), + setConnectionCatalog: (raw: string) => { + saved = raw; + return Promise.resolve(true); + }, + }, + }); + const stale = yield* Layer.build(ConnectionStorage.layer); + const other = yield* Layer.build(ConnectionStorage.layer); + yield* Context.get(stale, Persistence.ConnectionTargetStore).list; + const target = new BearerConnectionTarget({ + connectionId: "other-window", + environmentId: EnvironmentId.make("other-window"), + label: "Other window", + }); + yield* Context.get(other, Persistence.ConnectionRegistrationStore).register( + new BearerConnectionRegistration({ + target, + profile: new BearerConnectionProfile({ + connectionId: target.connectionId, + environmentId: target.environmentId, + label: target.label, + httpBaseUrl: "https://other.example.test/", + wsBaseUrl: "wss://other.example.test/", + }), + credential: new BearerConnectionCredential({ token: "other-token" }), + }), + [target], + ); + + yield* Context.get(stale, Persistence.ConnectionRegistrationStore).assertWritable; + + const catalog = decodeCatalog(saved!); + expect(catalog.targets.map((entry) => entry.environmentId)).toEqual(["other-window"]); + expect(catalog.credentials).toHaveLength(1); + }).pipe(Effect.scoped), + ); }); describe("IndexedDB connection recovery", () => { diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 2d7b026cf8e2b..f93836eccbb90 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -383,32 +383,44 @@ export interface CatalogBackend { readonly read: Effect.Effect; readonly write: (raw: string) => Effect.Effect; readonly quarantine?: (raw: string) => Effect.Effect; + /** Fails when `write` would be refused, rewriting only what is stored now. */ + readonly probeWrite?: Effect.Effect; } export function makeCatalogBackend(database: DatabaseHandle): CatalogBackend { const bridge = window.desktopBridge; if (bridge !== undefined) { - return { - read: Effect.tryPromise({ - try: () => bridge.getConnectionCatalog(), - catch: (cause) => catalogError("load", cause), - }), - write: (raw) => - Effect.tryPromise({ - try: () => bridge.setConnectionCatalog(raw), - catch: (cause) => catalogError("save", cause), - }).pipe( - Effect.flatMap((stored) => - stored - ? Effect.void - : Effect.fail( - catalogError( - "save", - "T3 Code could not encrypt saved connections because no system keyring is available or unlocked. On Linux, start and unlock a Secret Service keyring such as GNOME Keyring or KWallet, then restart T3 Code.", - ), + const read = Effect.tryPromise({ + try: () => bridge.getConnectionCatalog(), + catch: (cause) => catalogError("load", cause), + }); + const write = (raw: string) => + Effect.tryPromise({ + try: () => bridge.setConnectionCatalog(raw), + catch: (cause) => catalogError("save", cause), + }).pipe( + Effect.flatMap((stored) => + stored + ? Effect.void + : Effect.fail( + catalogError( + "save", + "T3 Code could not encrypt saved connections because no system keyring is available or unlocked. On Linux, start and unlock a Secret Service keyring such as GNOME Keyring or KWallet, then restart T3 Code.", ), - ), + ), ), + ); + return { + read, + write, + // The desktop reads null both when nothing is saved and when it cannot decrypt. + probeWrite: read.pipe( + Effect.flatMap((raw) => + raw === null + ? encodeCatalog(EMPTY_CONNECTION_CATALOG_DOCUMENT).pipe(Effect.flatMap(write)) + : write(raw), + ), + ), }; } @@ -427,6 +439,7 @@ interface CatalogStore { readonly update: ( transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType, ) => Effect.Effect; + readonly assertWritable: Effect.Effect; } export const makeCatalogStore = Effect.fn("web.connectionStorage.makeCatalogStore")(function* ( @@ -488,7 +501,9 @@ export const makeCatalogStore = Effect.fn("web.connectionStorage.makeCatalogStor }, ); - return { read, update } satisfies CatalogStore; + const assertWritable = lock.withPermits(1)(backend.probeWrite ?? Effect.void); + + return { read, update, assertWritable } satisfies CatalogStore; }); // The stored names predate the rename and stay, so a tab still on an older version and an @@ -636,9 +651,9 @@ export const layer = Layer.effectContext( ), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ - assertWritable: catalog - .update((document) => document) - .pipe(Effect.mapError((cause) => persistenceError("register-connection", cause))), + assertWritable: catalog.assertWritable.pipe( + Effect.mapError((cause) => persistenceError("register-connection", cause)), + ), register: Effect.fn("Persistence.ConnectionRegistrationStore.register")( function (registration, routes) { return catalog diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index a9296ba65b99d..4ad5f0f464ecf 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -491,7 +491,7 @@ describe("connection onboarding", () => { const saveError = new Persistence.ConnectionPersistenceError({ operation: "register-connection", message: - "Could not register connection: Desktop secure storage is unavailable in this system context.", + "Could not register connection: T3 Code could not encrypt saved connections because no system keyring is available or unlocked. On Linux, start and unlock a Secret Service keyring such as GNOME Keyring or KWallet, then restart T3 Code.", }); return Effect.gen(function* () { const onboarding = yield* Onboarding.ConnectionOnboarding; diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index c27338c0a2422..7a4a4bc234306 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -54,7 +54,8 @@ export class ConnectionRegistrationStore extends Context.Service< ConnectionRegistrationStore, { /** - * Fails, without changing what is stored, when `register` could not save. + * Fails when saves are known to be refused, such as when the desktop cannot encrypt them. + * Passing does not guarantee that `register` will succeed. */ readonly assertWritable: Effect.Effect; /** From e9d21929d02094ed2e4ad8c8d848df1d6313f3fd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 08:02:02 +0000 Subject: [PATCH 4/6] fix(desktop): check catalog encryption without rewriting the catalog Before redeeming a pairing code, the desktop probe read the connection catalog over IPC and wrote the same bytes back. Two runtimes sharing a catalog could interleave so one window's stale write erased a connection another had just saved. The desktop now exposes the encryption check its saves already use as canSaveConnectionCatalog, and the web probe asks it instead of writing. The keyring error is shortened so the pairing failure toast shows the whole remediation instead of clamping it. Co-Authored-By: Claude Opus 5.5 --- .../app/DesktopConnectionCatalogStore.test.ts | 1 + .../src/app/DesktopConnectionCatalogStore.ts | 2 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 7 +- apps/desktop/src/ipc/channels.ts | 1 + .../src/ipc/methods/connectionCatalog.ts | 10 +++ apps/desktop/src/preload.ts | 2 + apps/web/src/connection/storage.test.ts | 62 +++-------------- apps/web/src/connection/storage.ts | 67 ++++++++----------- .../src/connection/onboarding.test.ts | 2 +- packages/contracts/src/ipc.ts | 1 + 10 files changed, 61 insertions(+), 94 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 8b4fc23a99335..f88bae280e023 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -116,6 +116,7 @@ describe("DesktopConnectionCatalogStore", () => { withStore( Effect.gen(function* () { const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + assert.isFalse(yield* store.canSave); assert.isFalse(yield* store.set("{}")); assert.deepStrictEqual(yield* store.get, Option.none()); }), diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index ceb3f293550bf..a54b41a446374 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -157,6 +157,7 @@ export class DesktopConnectionCatalogStore extends Context.Service< | DesktopConnectionCatalogStoreMigrationError | DesktopConnectionCatalogStoreProtectionError >; + readonly canSave: Effect.Effect; readonly set: ( catalog: string, ) => Effect.Effect< @@ -495,6 +496,7 @@ export const make = Effect.gen(function* () { ); return Option.some(decrypted); }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), + canSave: encryptionAvailable.pipe(Effect.withSpan("desktop.connectionCatalogStore.canSave")), set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { if (!(yield* encryptionAvailable)) { return false; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 35333ac3d33a4..338bc945f1086 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -4,7 +4,11 @@ import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./metho import * as DesktopIpc from "./DesktopIpc.ts"; import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; -import { getConnectionCatalog, setConnectionCatalog } from "./methods/connectionCatalog.ts"; +import { + canSaveConnectionCatalog, + getConnectionCatalog, + setConnectionCatalog, +} from "./methods/connectionCatalog.ts"; import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled, @@ -115,6 +119,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(dismissSnapShotAnimation); yield* ipc.handle(acknowledgeSnapShot); yield* ipc.handle(setConnectionCatalog); + yield* ipc.handle(canSaveConnectionCatalog); yield* ipc.handle(discoverSshHosts); yield* ipc.handle(resolveSshHost); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index bba4f6449b8c1..b482594da8e74 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -55,6 +55,7 @@ export const DISMISS_SNAP_SHOT_ANIMATION_CHANNEL = "desktop:dismiss-snap-shot-an export const ACKNOWLEDGE_SNAP_SHOT_CHANNEL = "desktop:acknowledge-snap-shot"; export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; +export const CAN_SAVE_CONNECTION_CATALOG_CHANNEL = "desktop:can-save-connection-catalog"; export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; export const RESOLVE_SSH_HOST_CHANNEL = "desktop:resolve-ssh-host"; export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; diff --git a/apps/desktop/src/ipc/methods/connectionCatalog.ts b/apps/desktop/src/ipc/methods/connectionCatalog.ts index 0f913d0f300a3..aaa1ee8b64ca2 100644 --- a/apps/desktop/src/ipc/methods/connectionCatalog.ts +++ b/apps/desktop/src/ipc/methods/connectionCatalog.ts @@ -25,3 +25,13 @@ export const setConnectionCatalog = DesktopIpc.makeIpcMethod({ return yield* store.set(catalog); }), }); + +export const canSaveConnectionCatalog = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.CAN_SAVE_CONNECTION_CATALOG_CHANNEL, + payload: Schema.Void, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.connectionCatalog.canSave")(function* () { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + return yield* store.canSave; + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 965b7db5d6d40..2ef6879183d42 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -138,6 +138,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), setConnectionCatalog: (catalog) => ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), + canSaveConnectionCatalog: () => + ipcRenderer.invoke(IpcChannels.CAN_SAVE_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), resolveSshHost: (alias) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_HOST_CHANNEL, alias), ensureSshEnvironment: async (target, options) => diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 643cde116c299..3d3cdc0e10ca0 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -1,18 +1,12 @@ import { - BearerConnectionCredential, - BearerConnectionProfile, - BearerConnectionRegistration, - BearerConnectionTarget, ConnectionTransientError, PrimaryConnectionTarget, } from "@t3tools/client-runtime/connection"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { ConnectionCatalogDocument, Persistence } from "@t3tools/client-runtime/platform"; import { describe, expect, it } from "@effect/vitest"; -import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; -import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -103,12 +97,14 @@ describe("ConnectionStorage.makeCatalogBackend", () => { }); describe("ConnectionStorage registration writability", () => { - it.effect("fails when the desktop cannot encrypt the catalog", () => + it.effect("fails without writing when the desktop cannot save the catalog", () => Effect.gen(function* () { - // Without a keyring the desktop reads no catalog and refuses every save. const setConnectionCatalog = vi.fn(() => Promise.resolve(false)); vi.stubGlobal("window", { - desktopBridge: { getConnectionCatalog: () => Promise.resolve(null), setConnectionCatalog }, + desktopBridge: { + canSaveConnectionCatalog: () => Promise.resolve(false), + setConnectionCatalog, + }, }); const error = yield* Effect.gen(function* () { @@ -116,53 +112,11 @@ describe("ConnectionStorage registration writability", () => { return yield* Effect.flip(registrations.assertWritable); }).pipe(Effect.provide(ConnectionStorage.layer)); - expect(error.operation).toBe("register-connection"); - expect(error.message).toContain("Secret Service keyring"); + expect(error.message).toContain("Unlock GNOME Keyring or KWallet"); + // Writing back what was read would clobber a connection another runtime saved meanwhile. + expect(setConnectionCatalog).not.toHaveBeenCalled(); }), ); - - it.effect("keeps a connection another window saved after this window loaded its catalog", () => - Effect.gen(function* () { - let saved: string | null = null; - vi.stubGlobal("window", { - desktopBridge: { - getConnectionCatalog: () => Promise.resolve(saved), - setConnectionCatalog: (raw: string) => { - saved = raw; - return Promise.resolve(true); - }, - }, - }); - const stale = yield* Layer.build(ConnectionStorage.layer); - const other = yield* Layer.build(ConnectionStorage.layer); - yield* Context.get(stale, Persistence.ConnectionTargetStore).list; - const target = new BearerConnectionTarget({ - connectionId: "other-window", - environmentId: EnvironmentId.make("other-window"), - label: "Other window", - }); - yield* Context.get(other, Persistence.ConnectionRegistrationStore).register( - new BearerConnectionRegistration({ - target, - profile: new BearerConnectionProfile({ - connectionId: target.connectionId, - environmentId: target.environmentId, - label: target.label, - httpBaseUrl: "https://other.example.test/", - wsBaseUrl: "wss://other.example.test/", - }), - credential: new BearerConnectionCredential({ token: "other-token" }), - }), - [target], - ); - - yield* Context.get(stale, Persistence.ConnectionRegistrationStore).assertWritable; - - const catalog = decodeCatalog(saved!); - expect(catalog.targets.map((entry) => entry.environmentId)).toEqual(["other-window"]); - expect(catalog.credentials).toHaveLength(1); - }).pipe(Effect.scoped), - ); }); describe("IndexedDB connection recovery", () => { diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index f93836eccbb90..3480c9da2dcc0 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -383,44 +383,37 @@ export interface CatalogBackend { readonly read: Effect.Effect; readonly write: (raw: string) => Effect.Effect; readonly quarantine?: (raw: string) => Effect.Effect; - /** Fails when `write` would be refused, rewriting only what is stored now. */ - readonly probeWrite?: Effect.Effect; + /** Fails when `write` is known to be refused, without writing anything. */ + readonly assertWritable?: Effect.Effect; } export function makeCatalogBackend(database: DatabaseHandle): CatalogBackend { const bridge = window.desktopBridge; if (bridge !== undefined) { - const read = Effect.tryPromise({ - try: () => bridge.getConnectionCatalog(), - catch: (cause) => catalogError("load", cause), - }); - const write = (raw: string) => - Effect.tryPromise({ - try: () => bridge.setConnectionCatalog(raw), - catch: (cause) => catalogError("save", cause), - }).pipe( - Effect.flatMap((stored) => - stored - ? Effect.void - : Effect.fail( - catalogError( - "save", - "T3 Code could not encrypt saved connections because no system keyring is available or unlocked. On Linux, start and unlock a Secret Service keyring such as GNOME Keyring or KWallet, then restart T3 Code.", - ), - ), - ), - ); + // The pairing failure toast clamps descriptions of 180+ characters, which would hide the fix. + const failUnlessSaved = (saved: boolean) => + saved + ? Effect.void + : Effect.fail( + catalogError( + "save", + "No unlocked keyring. Unlock GNOME Keyring or KWallet, then restart T3 Code.", + ), + ); return { - read, - write, - // The desktop reads null both when nothing is saved and when it cannot decrypt. - probeWrite: read.pipe( - Effect.flatMap((raw) => - raw === null - ? encodeCatalog(EMPTY_CONNECTION_CATALOG_DOCUMENT).pipe(Effect.flatMap(write)) - : write(raw), - ), - ), + read: Effect.tryPromise({ + try: () => bridge.getConnectionCatalog(), + catch: (cause) => catalogError("load", cause), + }), + write: (raw) => + Effect.tryPromise({ + try: () => bridge.setConnectionCatalog(raw), + catch: (cause) => catalogError("save", cause), + }).pipe(Effect.flatMap(failUnlessSaved)), + assertWritable: Effect.tryPromise({ + try: () => bridge.canSaveConnectionCatalog(), + catch: (cause) => catalogError("save", cause), + }).pipe(Effect.flatMap(failUnlessSaved)), }; } @@ -439,7 +432,6 @@ interface CatalogStore { readonly update: ( transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType, ) => Effect.Effect; - readonly assertWritable: Effect.Effect; } export const makeCatalogStore = Effect.fn("web.connectionStorage.makeCatalogStore")(function* ( @@ -501,9 +493,7 @@ export const makeCatalogStore = Effect.fn("web.connectionStorage.makeCatalogStor }, ); - const assertWritable = lock.withPermits(1)(backend.probeWrite ?? Effect.void); - - return { read, update, assertWritable } satisfies CatalogStore; + return { read, update } satisfies CatalogStore; }); // The stored names predate the rename and stay, so a tab still on an older version and an @@ -633,7 +623,8 @@ export const layer = Layer.effectContext( makeDatabaseHandle(), (owned) => owned.close, ); - const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); + const catalogBackend = makeCatalogBackend(database); + const catalog = yield* makeCatalogStore(catalogBackend); const pullRequestRoutingPermissions = makeBrowserPullRequestRoutingPermissions(); // Remembers each thread row's encoding, so a save only encodes rows that changed. const encodeStoredShellSnapshot = makeStoredShellSnapshotEncoder({ @@ -651,7 +642,7 @@ export const layer = Layer.effectContext( ), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ - assertWritable: catalog.assertWritable.pipe( + assertWritable: (catalogBackend.assertWritable ?? Effect.void).pipe( Effect.mapError((cause) => persistenceError("register-connection", cause)), ), register: Effect.fn("Persistence.ConnectionRegistrationStore.register")( diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 4ad5f0f464ecf..281ae8ffa8561 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -491,7 +491,7 @@ describe("connection onboarding", () => { const saveError = new Persistence.ConnectionPersistenceError({ operation: "register-connection", message: - "Could not register connection: T3 Code could not encrypt saved connections because no system keyring is available or unlocked. On Linux, start and unlock a Secret Service keyring such as GNOME Keyring or KWallet, then restart T3 Code.", + "Could not register connection: ConnectionTransientError: Could not save the local connection catalog: No unlocked keyring. Unlock GNOME Keyring or KWallet, then restart T3 Code.", }); return Effect.gen(function* () { const onboarding = yield* Onboarding.ConnectionOnboarding; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f460619f11355..adf45639e4e04 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1039,6 +1039,7 @@ export interface DesktopBridge { setClientSettings: (settings: ClientSettings) => Promise; getConnectionCatalog: () => Promise; setConnectionCatalog: (catalog: string) => Promise; + canSaveConnectionCatalog: () => Promise; discoverSshHosts: () => Promise; resolveSshHost: (alias: string) => Promise; requestSnapShotPermissions: (includeAccessibility: boolean) => Promise; From 75f25eba0dde05b73f5577582ca9425e5aa37e3b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:20:05 +0000 Subject: [PATCH 5/6] fix(client-runtime): pair host-managed environments without writable storage The storage preflight before redeeming a pairing code now runs in the registry, which skips it for local and WSL environments the desktop host manages, since registering those saves nothing. AI-assisted; verified with the tests and tools listed above. --- .../src/connection/onboarding.test.ts | 17 +++++++++-------- .../client-runtime/src/connection/onboarding.ts | 6 +++--- .../client-runtime/src/connection/registry.ts | 15 +++++++++++++++ .../src/state/threads-atoms.test.ts | 1 + 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 281ae8ffa8561..6ccb8965dd119 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -159,6 +159,11 @@ function layerPairingHttp( return RpcHttp.layerRemoteHttpClient(fetchFn); } +const SAVE_ERROR = new Persistence.ConnectionPersistenceError({ + operation: "register-connection", + message: "No unlocked keyring.", +}); + function layerPairingOnboarding( options?: Parameters[1] & { readonly saveError?: Persistence.ConnectionPersistenceError; @@ -459,11 +464,12 @@ describe("connection onboarding", () => { unsupportedReason: UNSUPPORTED_ERROR.message, }); + // The host owns this entry, so storage that refuses saves must not block pairing. const environmentId = yield* onboarding.registerPairing(PAIRING_INPUT); expect(environmentId).toBe(PAIRED_ENVIRONMENT_ID); expect(yield* SubscriptionRef.get(registry.entries)).toEqual(before); - }).pipe(Effect.provide(layerPairingOnboarding())), + }).pipe(Effect.provide(layerPairingOnboarding({ saveError: SAVE_ERROR }))), ); it.effect("leaves a switched-off environment unchanged when the pairing exchange fails", () => @@ -488,21 +494,16 @@ describe("connection onboarding", () => { it.effect("does not consume a pairing credential when the connection cannot be saved", () => { const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; - const saveError = new Persistence.ConnectionPersistenceError({ - operation: "register-connection", - message: - "Could not register connection: ConnectionTransientError: Could not save the local connection catalog: No unlocked keyring. Unlock GNOME Keyring or KWallet, then restart T3 Code.", - }); return Effect.gen(function* () { const onboarding = yield* Onboarding.ConnectionOnboarding; const error = yield* onboarding.registerPairing(PAIRING_INPUT).pipe(Effect.flip); - expect(error).toEqual(saveError); + expect(error).toEqual(SAVE_ERROR); expect(calls.map((call) => call.url)).toEqual([ "https://remote.example.test/.well-known/t3/environment", ]); - }).pipe(Effect.provide(layerPairingOnboarding({ saveError }, calls))); + }).pipe(Effect.provide(layerPairingOnboarding({ saveError: SAVE_ERROR }, calls))); }); it.effect("rejects an incompatible server without consuming the pairing credential", () => diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 4b11dfdf58597..c4544d2e47533 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -139,7 +139,9 @@ export const preparePairingRegistration = Effect.fn( return yield* compatibilityError; } // A code redeemed for a connection that cannot be saved is spent for nothing. - yield* (yield* Persistence.ConnectionRegistrationStore).assertWritable; + yield* (yield* EnvironmentRegistry.EnvironmentRegistry).assertCanRegister( + descriptor.environmentId, + ); const access = yield* bootstrapRemoteBearerSession({ httpBaseUrl: target.httpBaseUrl, credential: target.credential, @@ -306,7 +308,6 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const ssh = yield* ClientCapabilities.SshEnvironmentGateway; const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; - const registrations = yield* Persistence.ConnectionRegistrationStore; // Provisioning retains the delegated onboarding operation spans. return ConnectionOnboarding.of({ @@ -317,7 +318,6 @@ export const make = Effect.gen(function* () { Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), Effect.provideService(ClientCapabilities.ClientPresentation, presentation), Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(Persistence.ConnectionRegistrationStore, registrations), ), registerSsh: Effect.fnUntraced( function* (input) { diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 12f8aa5ec505f..562d2cfa50524 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -115,6 +115,13 @@ export class EnvironmentRegistry extends Context.Service< readonly assertKnownAddress: ( route: ConnectionRoute, ) => Effect.Effect; + /** + * Fails when `register` would be refused for lack of writable storage. Platform-managed + * environments pass, since `register` saves nothing for them. + */ + readonly assertCanRegister: ( + environmentId: EnvironmentId, + ) => Effect.Effect; readonly reconcilePlatform: ( registrations: ReadonlyArray, ) => Effect.Effect; @@ -558,6 +565,13 @@ export const make = Effect.gen(function* () { } }); + const assertCanRegister = Effect.fn("EnvironmentRegistry.assertCanRegister")(function* ( + environmentId: EnvironmentId, + ) { + if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) return; + yield* registrations.assertWritable; + }); + const register = Effect.fn("EnvironmentRegistry.register")(function* ( registration: ConnectionRegistration, options?: { readonly knownAddressOnly?: boolean }, @@ -1189,6 +1203,7 @@ export const make = Effect.gen(function* () { start, register, assertKnownAddress, + assertCanRegister, reconcilePlatform, remove, removeRoute, diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index 460f7e593990a..dcd80534969d6 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -133,6 +133,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? start: Effect.void, register: () => Effect.die("Unexpected environment registration"), assertKnownAddress: () => Effect.die("Unexpected address check"), + assertCanRegister: () => Effect.die("Unexpected storage check"), reconcilePlatform: () => Effect.die("Unexpected environment reconciliation"), remove: () => Effect.die("Unexpected environment removal"), removeRoute: () => Effect.die("Unexpected route removal"), From d9c1bdd1578ddf56fae8974841bd56e53617053f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:22:55 +0000 Subject: [PATCH 6/6] fix(web): give keyring guidance that fits the desktop platform The refused-save message named GNOME Keyring and KWallet on every platform. macOS and Windows now get a keychain hint instead. Drops the storage test that only asserted the message text and IPC wiring. AI-assisted; verified with the tests and tools listed above. --- apps/web/src/connection/storage.test.ts | 23 ----------------------- apps/web/src/connection/storage.ts | 12 ++++-------- docs/user/install.md | 4 ++-- 3 files changed, 6 insertions(+), 33 deletions(-) diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 3d3cdc0e10ca0..7afe2b1e8573f 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -96,29 +96,6 @@ describe("ConnectionStorage.makeCatalogBackend", () => { ); }); -describe("ConnectionStorage registration writability", () => { - it.effect("fails without writing when the desktop cannot save the catalog", () => - Effect.gen(function* () { - const setConnectionCatalog = vi.fn(() => Promise.resolve(false)); - vi.stubGlobal("window", { - desktopBridge: { - canSaveConnectionCatalog: () => Promise.resolve(false), - setConnectionCatalog, - }, - }); - - const error = yield* Effect.gen(function* () { - const registrations = yield* Persistence.ConnectionRegistrationStore; - return yield* Effect.flip(registrations.assertWritable); - }).pipe(Effect.provide(ConnectionStorage.layer)); - - expect(error.message).toContain("Unlock GNOME Keyring or KWallet"); - // Writing back what was read would clobber a connection another runtime saved meanwhile. - expect(setConnectionCatalog).not.toHaveBeenCalled(); - }), - ); -}); - describe("IndexedDB connection recovery", () => { it.effect("reopens after a forced close and finalizes the current connection", () => Effect.gen(function* () { diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 6646be5224e9d..9ea72c22866b4 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -490,15 +490,11 @@ export function makeCatalogBackend(database: DatabaseHandle): CatalogBackend { const bridge = window.desktopBridge; if (bridge !== undefined) { // The pairing failure toast clamps descriptions of 180+ characters, which would hide the fix. + const unsavedReason = navigator.platform.startsWith("Linux") + ? "No unlocked keyring. Unlock GNOME Keyring or KWallet, then restart T3 Code." + : "Secure storage is unavailable. Unlock your keychain, then restart T3 Code."; const failUnlessSaved = (saved: boolean) => - saved - ? Effect.void - : Effect.fail( - catalogError( - "save", - "No unlocked keyring. Unlock GNOME Keyring or KWallet, then restart T3 Code.", - ), - ); + saved ? Effect.void : Effect.fail(catalogError("save", unsavedReason)); return { read: Effect.tryPromise({ try: () => bridge.getConnectionCatalog(), diff --git a/docs/user/install.md b/docs/user/install.md index 44aeeb1c56f19..7c9fe176e0bdc 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -105,8 +105,8 @@ The AUR packages currently support x86_64 only; on ARM64, use the AppImage. Update AUR installs with your AUR helper, such as `yay -Syu`, instead of the in-app updater. -On Linux, the desktop app needs an unlocked Secret Service keyring, such as -GNOME Keyring or KWallet, to save connections to other machines. +The desktop app needs an unlocked Secret Service keyring, such as GNOME +Keyring or KWallet, to save connections to other machines. ### Nix (nixpkgs)