Make relay opt-in when pairing devices (#2706)
* feat: make relay opt-in for device pairing * fix(relay): keep pairing state authoritative * fix(relay): refresh pairing state across transports * fix(onboard): honor explicit relay opt-out * fix(relay): surface transport startup failures * fix(pairing): gate offers for older daemons * test(cli): rely on real pairing transport coverage * fix(cli): report live relay without a pid file * fix(relay): synchronize config during daemon startup * fix(relay): preserve mutability for ignored overrides * fix(cli): use reachable daemon for pairing * fix(app): redirect invalid pair device routes * fix(pairing): support remote host device pairing Keep Pair Device host-relative through the existing settings and daemon RPC flow. Route CLI output through an injected adapter so workflow tests do not mutate process globals. * fix(pairing): validate pairing destinations Expose the host-relative Pair Device flow on every supported app platform. Reject a reachable daemon whose identity does not match the requested Paseo home before reading or mutating its pairing state. * fix(pairing): render QR codes across platforms Generate SVG text without DOM or canvas and render it through the existing cross-platform SVG stack. * fix(cli): pair from persisted relay state Allow explicit relay pairing while the daemon is stopped when relay is already enabled in persisted configuration.
This commit is contained in:
@@ -25,6 +25,7 @@ const test = base.extend<{
|
||||
paseoE2ESetup: void;
|
||||
outdatedDaemon: OutdatedDaemon;
|
||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||
relayConfigOutdatedDaemon: OutdatedDaemon;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
}>({
|
||||
@@ -129,6 +130,14 @@ const test = base.extend<{
|
||||
await provide(daemon);
|
||||
await daemon.close();
|
||||
},
|
||||
relayConfigOutdatedDaemon: async ({}, provide) => {
|
||||
const daemon = await startOutdatedDaemon({
|
||||
daemonStatusRpcCapability: false,
|
||||
relayConfigCapability: false,
|
||||
});
|
||||
await provide(daemon);
|
||||
await daemon.close();
|
||||
},
|
||||
projectPickerFixture: async ({}, provide) => {
|
||||
const resource = await createProjectPickerFixture();
|
||||
const { fixture } = resource;
|
||||
|
||||
@@ -780,6 +780,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENABLED: "true",
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
|
||||
@@ -24,6 +24,8 @@ type OutdatedDaemonMessage = OutdatedDaemonReadyMessage | OutdatedDaemonErrorMes
|
||||
|
||||
export async function startOutdatedDaemon(options?: {
|
||||
desktopManaged?: boolean;
|
||||
daemonStatusRpcCapability?: boolean;
|
||||
relayConfigCapability?: boolean;
|
||||
}): Promise<OutdatedDaemon> {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -37,6 +39,8 @@ export async function startOutdatedDaemon(options?: {
|
||||
...process.env,
|
||||
E2E_METRO_PORT: metroPort,
|
||||
E2E_DESKTOP_MANAGED: options?.desktopManaged === true ? "1" : "0",
|
||||
E2E_DAEMON_STATUS_RPC_CAPABILITY: options?.daemonStatusRpcCapability === false ? "0" : "1",
|
||||
E2E_RELAY_CONFIG_CAPABILITY: options?.relayConfigCapability === false ? "0" : "1",
|
||||
},
|
||||
execArgv: ["--import", "tsx"],
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { once } from "node:events";
|
||||
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -10,10 +10,18 @@ export interface IsolatedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
paseoHome: string;
|
||||
getPid(): number | undefined;
|
||||
restart(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface IsolatedHostDaemonOptions {
|
||||
mutableRelay?: {
|
||||
enabled: boolean;
|
||||
endpoint?: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -76,7 +84,10 @@ async function stopProcess(child: ChildProcess): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function startIsolatedHostDaemon(serverId: string): Promise<IsolatedHostDaemon> {
|
||||
export async function startIsolatedHostDaemon(
|
||||
serverId: string,
|
||||
options: IsolatedHostDaemonOptions = {},
|
||||
): Promise<IsolatedHostDaemon> {
|
||||
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
|
||||
let port = await getAvailablePort();
|
||||
while (port === 6767 || port === primaryPort) port = await getAvailablePort();
|
||||
@@ -85,6 +96,26 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
|
||||
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
|
||||
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-"));
|
||||
if (options.mutableRelay) {
|
||||
const endpoint =
|
||||
options.mutableRelay.endpoint ??
|
||||
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
daemon: {
|
||||
relay: {
|
||||
enabled: options.mutableRelay.enabled,
|
||||
endpoint,
|
||||
publicEndpoint: endpoint,
|
||||
useTls: false,
|
||||
publicUseTls: false,
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
const serverDir = path.resolve(__dirname, "../../../server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||
@@ -96,7 +127,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
|
||||
PASEO_SERVER_ID: serverId,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
PASEO_RELAY_ENABLED: "0",
|
||||
PASEO_RELAY_ENABLED: options.mutableRelay ? undefined : "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
}),
|
||||
@@ -135,6 +166,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
|
||||
serverId,
|
||||
port,
|
||||
paseoHome,
|
||||
getPid: () => child.pid,
|
||||
restart: async () => {
|
||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||
await stopProcess(child);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { IsolatedHostDaemon } from "./isolated-host-daemon";
|
||||
import type { OutdatedDaemon } from "./daemon-update";
|
||||
import { openSettings, gotoAppShell } from "./app";
|
||||
import {
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
seedSavedSettingsHosts,
|
||||
selectSettingsHost,
|
||||
} from "./settings";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
|
||||
interface PairingHostInput {
|
||||
serverId: string;
|
||||
label: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export async function prepareLocalPairingHost(
|
||||
page: Page,
|
||||
daemon: IsolatedHostDaemon | OutdatedDaemon,
|
||||
additionalHosts: PairingHostInput[] = [],
|
||||
): Promise<void> {
|
||||
await page.addInitScript((localServerId) => {
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "darwin",
|
||||
invoke: async (command: string) => {
|
||||
if (command === "desktop_daemon_status") {
|
||||
return {
|
||||
serverId: localServerId,
|
||||
status: "running",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "",
|
||||
version: null,
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (command === "get_desktop_settings") {
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
opener: {
|
||||
openUrl: async (url: string) => {
|
||||
localStorage.setItem("@paseo:e2e-opened-url", url);
|
||||
},
|
||||
},
|
||||
};
|
||||
}, daemon.serverId);
|
||||
|
||||
await preparePairingHost(page, daemon, additionalHosts);
|
||||
}
|
||||
|
||||
export async function preparePairingHost(
|
||||
page: Page,
|
||||
daemon: IsolatedHostDaemon | OutdatedDaemon,
|
||||
additionalHosts: PairingHostInput[] = [],
|
||||
): Promise<void> {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: daemon.serverId,
|
||||
label: "Local pairing host",
|
||||
endpoint: "port" in daemon ? `127.0.0.1:${daemon.port}` : daemon.endpoint,
|
||||
},
|
||||
...additionalHosts,
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, daemon.serverId);
|
||||
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
||||
await openSettingsHostSection(page, daemon.serverId, "pair-device");
|
||||
await expect(page.getByTestId("host-page-pair-device-row")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openPairDeviceModal(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: /Pair a device/ }).click();
|
||||
await expect(page.getByTestId("host-page-pair-device-card")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectRelayConsent(page: Page): Promise<void> {
|
||||
const modal = page.getByTestId("host-page-pair-device-card");
|
||||
await expect(modal.getByText("Enable relay?", { exact: true })).toBeVisible();
|
||||
await expect(modal.getByText(/end-to-end encrypted/)).toBeVisible();
|
||||
await expect(modal.getByRole("link", { name: "Read how Paseo relay works" })).toBeVisible();
|
||||
await expect(modal.getByText(/TCP, Tailscale, or another VPN/)).toBeVisible();
|
||||
await expect(modal.getByRole("img", { name: "Pairing QR code" })).toHaveCount(0);
|
||||
await expect(modal.getByRole("textbox", { name: "Pairing link" })).toHaveCount(0);
|
||||
await expect(modal.getByRole("button", { name: "Copy", exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function declineRelay(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Not now", exact: true }).click();
|
||||
await expect(page.getByTestId("host-page-pair-device-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
|
||||
const enableButton = page.getByRole("button", { name: "Enable relay", exact: true });
|
||||
await enableButton.click();
|
||||
await expect(page.getByRole("button", { name: "Enabling...", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
|
||||
await expect(page.getByText("Enable relay?", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectPairingOffer(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
|
||||
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function closePairDeviceModal(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByTestId("host-page-pair-device-card")
|
||||
.getByRole("button", { name: "Close", exact: true })
|
||||
.click();
|
||||
await expect(page.getByTestId("host-page-pair-device-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function reloadAndOpenPairDevice(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
|
||||
if (!nonce) throw new Error("Expected e2e seed nonce");
|
||||
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||
});
|
||||
await page.reload();
|
||||
await openPairDeviceModal(page);
|
||||
}
|
||||
|
||||
export async function enableRelayAndExpectFailure(
|
||||
page: Page,
|
||||
expectedError = "Relay is controlled by a daemon launch override",
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Enable relay", exact: true }).click();
|
||||
const modal = page.getByTestId("host-page-pair-device-card");
|
||||
await expect(modal.getByRole("alert")).toContainText(expectedError);
|
||||
await expect(modal.getByRole("button", { name: "Retry", exact: true })).toBeEnabled();
|
||||
await expect(modal.getByRole("textbox", { name: "Pairing link" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function retryRelayAndExpectFailure(
|
||||
page: Page,
|
||||
expectedError = "Relay is controlled by a daemon launch override",
|
||||
): Promise<void> {
|
||||
const modal = page.getByTestId("host-page-pair-device-card");
|
||||
await modal.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expect(modal.getByRole("alert")).toContainText(expectedError);
|
||||
await expect(modal.getByRole("button", { name: "Retry", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function openPairDeviceFromHome(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
|
||||
if (!nonce) throw new Error("Expected e2e seed nonce");
|
||||
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||
});
|
||||
await page.goto("/open-project");
|
||||
await page.getByTestId("open-project-pair-device").click();
|
||||
await expect(page.getByTestId("open-project-pair-device-modal")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectRelayUpdateRequired(page: Page): Promise<void> {
|
||||
const modal = page.getByTestId("host-page-pair-device-card");
|
||||
await expect(
|
||||
modal.getByText("Update the host to enable relay from Paseo Desktop."),
|
||||
).toBeVisible();
|
||||
await expect(modal.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
|
||||
await expect(modal.getByRole("textbox", { name: "Pairing link" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export function observePairingOfferRequests(page: Page, daemon: OutdatedDaemon): () => void {
|
||||
let requestCount = 0;
|
||||
const port = daemon.endpoint.slice(daemon.endpoint.lastIndexOf(":") + 1);
|
||||
page.on("websocket", (socket) => {
|
||||
if (!socket.url().includes(`:${port}`)) return;
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
const message = typeof payload === "string" ? payload : payload.toString();
|
||||
if (message.includes('"type":"daemon.get_pairing_offer.request"')) requestCount += 1;
|
||||
});
|
||||
});
|
||||
return () => expect(requestCount).toBe(0);
|
||||
}
|
||||
|
||||
export async function expectPairingDisconnected(page: Page): Promise<void> {
|
||||
const modal = page.getByTestId("host-page-pair-device-card");
|
||||
await expect(modal.getByRole("alert")).toContainText("Host is not connected");
|
||||
await expect(modal.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function switchPairDeviceToHost(page: Page, serverId: string): Promise<void> {
|
||||
await selectSettingsHost(page, serverId);
|
||||
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "pair-device"));
|
||||
await expect(page.getByTestId("host-page-pair-device-row")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openRelaySecurityDocs(page: Page): Promise<void> {
|
||||
await page.getByRole("link", { name: "Read how Paseo relay works" }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem("@paseo:e2e-opened-url")))
|
||||
.toBe("https://paseo.sh/docs/security");
|
||||
}
|
||||
|
||||
export function expectDaemonPidUnchanged(
|
||||
before: number | undefined,
|
||||
after: number | undefined,
|
||||
): void {
|
||||
expect(before).toBeDefined();
|
||||
expect(after).toBe(before);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export type SettingsSection = keyof typeof SECTION_LABELS;
|
||||
type HostSection =
|
||||
| "projects"
|
||||
| "connections"
|
||||
| "pair-device"
|
||||
| "agents"
|
||||
| "workspaces"
|
||||
| "providers"
|
||||
@@ -359,8 +360,7 @@ export async function expectProviderInstalledInSettings(
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
||||
export async function expectHostNoDaemonLifecycleRow(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./helpers/isolated-host-daemon";
|
||||
import { connectDaemonClient } from "./helpers/daemon-client-loader";
|
||||
import {
|
||||
closePairDeviceModal,
|
||||
declineRelay,
|
||||
enableRelayAndExpectFailure,
|
||||
enableRelayAndExpectOffer,
|
||||
expectDaemonPidUnchanged,
|
||||
expectPairingOffer,
|
||||
expectPairingDisconnected,
|
||||
expectRelayUpdateRequired,
|
||||
expectRelayConsent,
|
||||
openPairDeviceModal,
|
||||
openPairDeviceFromHome,
|
||||
openRelaySecurityDocs,
|
||||
observePairingOfferRequests,
|
||||
prepareLocalPairingHost,
|
||||
preparePairingHost,
|
||||
reloadAndOpenPairDevice,
|
||||
retryRelayAndExpectFailure,
|
||||
switchPairDeviceToHost,
|
||||
} from "./helpers/pair-device";
|
||||
|
||||
interface RelayConfigDaemonClient {
|
||||
close(): Promise<void>;
|
||||
connect(): Promise<void>;
|
||||
patchDaemonConfig(config: { relay: { enabled: boolean } }): Promise<unknown>;
|
||||
}
|
||||
|
||||
test.describe("local device relay pairing", () => {
|
||||
let relayOffDaemon: IsolatedHostDaemon;
|
||||
let relayEnableDaemon: IsolatedHostDaemon;
|
||||
let relayOverrideDaemon: IsolatedHostDaemon;
|
||||
let relayEnabledDaemon: IsolatedHostDaemon;
|
||||
let invalidRelayDaemon: IsolatedHostDaemon;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
[
|
||||
relayOffDaemon,
|
||||
relayEnableDaemon,
|
||||
relayOverrideDaemon,
|
||||
relayEnabledDaemon,
|
||||
invalidRelayDaemon,
|
||||
] = await Promise.all([
|
||||
startIsolatedHostDaemon("pair-device-relay-off", {
|
||||
mutableRelay: { enabled: false },
|
||||
}),
|
||||
startIsolatedHostDaemon("pair-device-relay-enable", {
|
||||
mutableRelay: { enabled: false },
|
||||
}),
|
||||
startIsolatedHostDaemon("pair-device-relay-override"),
|
||||
startIsolatedHostDaemon("pair-device-relay-enabled", {
|
||||
mutableRelay: { enabled: true },
|
||||
}),
|
||||
startIsolatedHostDaemon("pair-device-relay-invalid", {
|
||||
mutableRelay: { enabled: false, endpoint: "invalid-endpoint" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await Promise.all([
|
||||
relayOffDaemon.close(),
|
||||
relayEnableDaemon.close(),
|
||||
relayOverrideDaemon.close(),
|
||||
relayEnabledDaemon.close(),
|
||||
invalidRelayDaemon.close(),
|
||||
]);
|
||||
});
|
||||
|
||||
test("asks for consent and creates no QR when declined", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOffDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
await declineRelay(page);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
});
|
||||
|
||||
test("opens relay security documentation through the desktop opener", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOffDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await openRelaySecurityDocs(page);
|
||||
});
|
||||
|
||||
test("opens the same relay consent dialog from the home screen", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOffDaemon);
|
||||
await openPairDeviceFromHome(page);
|
||||
const modal = page.getByTestId("open-project-pair-device-modal");
|
||||
await expect(modal.getByText("Enable relay?", { exact: true })).toBeVisible();
|
||||
await expect(modal.getByRole("button", { name: "Enable relay", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens relay consent in browser web", async ({ page }) => {
|
||||
await preparePairingHost(page, relayOffDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
});
|
||||
|
||||
test("shows an actionable error when the daemon disconnects", async ({ page }) => {
|
||||
const daemon = await startIsolatedHostDaemon("pair-device-disconnect", {
|
||||
mutableRelay: { enabled: false },
|
||||
});
|
||||
try {
|
||||
await prepareLocalPairingHost(page, daemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
await daemon.close();
|
||||
await expectPairingDisconnected(page);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("pairs a device through the selected remote host", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOffDaemon, [
|
||||
{
|
||||
serverId: relayEnabledDaemon.serverId,
|
||||
label: "Remote pairing host",
|
||||
endpoint: `127.0.0.1:${relayEnabledDaemon.port}`,
|
||||
},
|
||||
]);
|
||||
await switchPairDeviceToHost(page, relayEnabledDaemon.serverId);
|
||||
await openPairDeviceModal(page);
|
||||
await expectPairingOffer(page);
|
||||
});
|
||||
|
||||
test("enables relay live and keeps the saved offer after reload", async ({ page }) => {
|
||||
const daemonPid = relayEnableDaemon.getPid();
|
||||
await prepareLocalPairingHost(page, relayEnableDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await enableRelayAndExpectOffer(page);
|
||||
expectDaemonPidUnchanged(daemonPid, relayEnableDaemon.getPid());
|
||||
await closePairDeviceModal(page);
|
||||
await reloadAndOpenPairDevice(page);
|
||||
await expectPairingOffer(page);
|
||||
});
|
||||
|
||||
test("keeps consent actionable when a launch override rejects enable", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOverrideDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
await enableRelayAndExpectFailure(page);
|
||||
await retryRelayAndExpectFailure(page);
|
||||
});
|
||||
|
||||
test("keeps consent actionable when relay transport startup fails", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, invalidRelayDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
await enableRelayAndExpectFailure(page, "Invalid host:port");
|
||||
await retryRelayAndExpectFailure(page, "Invalid host:port");
|
||||
});
|
||||
|
||||
test("shows an offer immediately when relay is already enabled", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayEnabledDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectPairingOffer(page);
|
||||
});
|
||||
|
||||
test("refreshes the mounted offer when another client disables relay", async ({ page }) => {
|
||||
const daemon = await startIsolatedHostDaemon("pair-device-live-config-push", {
|
||||
mutableRelay: { enabled: true },
|
||||
});
|
||||
const client = await connectDaemonClient<RelayConfigDaemonClient>({
|
||||
clientIdPrefix: "pair-device-live-config-push",
|
||||
port: daemon.port,
|
||||
});
|
||||
try {
|
||||
await prepareLocalPairingHost(page, daemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectPairingOffer(page);
|
||||
await client.patchDaemonConfig({ relay: { enabled: false } });
|
||||
await expectRelayConsent(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("asks for a host update when live relay config is unsupported", async ({
|
||||
page,
|
||||
relayConfigOutdatedDaemon,
|
||||
}) => {
|
||||
const expectNoUnsupportedRequest = observePairingOfferRequests(page, relayConfigOutdatedDaemon);
|
||||
await prepareLocalPairingHost(page, relayConfigOutdatedDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayUpdateRequired(page);
|
||||
expectNoUnsupportedRequest();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
expectHostInjectMcpCard,
|
||||
expectHostActionCards,
|
||||
expectHostProvidersCard,
|
||||
expectHostNoLocalOnlyRows,
|
||||
expectHostNoDaemonLifecycleRow,
|
||||
expectRetiredSidebarSectionsAbsent,
|
||||
expectHostPageVisible,
|
||||
expectLocalHostEntryFirst,
|
||||
@@ -126,7 +126,7 @@ test.describe("Settings host page", () => {
|
||||
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
||||
});
|
||||
|
||||
test("host section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||
test("host section does not render daemon lifecycle controls for a remote daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
@@ -136,8 +136,8 @@ test.describe("Settings host page", () => {
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "host");
|
||||
|
||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||
await expectHostNoLocalOnlyRows(page);
|
||||
// TODO: add a local-daemon fixture for positive daemon lifecycle coverage.
|
||||
await expectHostNoDaemonLifecycleRow(page);
|
||||
});
|
||||
|
||||
test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function daemonPairingOfferQueryKey(serverId: string) {
|
||||
return ["daemon-pairing-offer", serverId] as const;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/prot
|
||||
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
||||
import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
|
||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
|
||||
import { providersSnapshotQueryKey } from "@/data/providers-snapshot";
|
||||
import {
|
||||
checkoutDiffPushRoute,
|
||||
@@ -34,6 +35,7 @@ type RouterHandler = (message: RouterMessage) => void;
|
||||
type RouterClient = Parameters<typeof mountServerDataPushRouter>[0]["client"];
|
||||
|
||||
const daemonConfig: MutableDaemonConfig = {
|
||||
relay: { enabled: false },
|
||||
mcp: { injectIntoAgents: true },
|
||||
browserTools: { enabled: false },
|
||||
providers: {},
|
||||
@@ -145,6 +147,8 @@ describe("server data push router", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const fake = createFakeClient();
|
||||
const serverId = "server-1";
|
||||
const pairingOfferKey = daemonPairingOfferQueryKey(serverId);
|
||||
queryClient.setQueryData(pairingOfferKey, { relayEnabled: true, url: "https://pairing" });
|
||||
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
|
||||
|
||||
fake.emit(providerUpdate("2026-01-01T00:00:00.000Z"));
|
||||
@@ -159,6 +163,7 @@ describe("server data push router", () => {
|
||||
requestId: "providers_snapshot_update",
|
||||
});
|
||||
expect(queryClient.getQueryData(daemonConfigQueryKey(serverId))).toEqual(daemonConfig);
|
||||
expect(queryClient.getQueryState(pairingOfferKey)?.isInvalidated).toBe(true);
|
||||
|
||||
unmount();
|
||||
fake.emit(providerUpdate("2026-01-01T00:00:01.000Z"));
|
||||
@@ -502,12 +507,14 @@ describe("server data push router", () => {
|
||||
const otherServerId = "server-2";
|
||||
const providerKey = providersSnapshotQueryKey(serverId);
|
||||
const daemonConfigKey = daemonConfigQueryKey(serverId);
|
||||
const pairingOfferKey = daemonPairingOfferQueryKey(serverId);
|
||||
const diffKey = checkoutDiffQueryKey(serverId, "/repo", "uncommitted", undefined, false);
|
||||
const terminalKey = buildTerminalsQueryKey(serverId, "/repo", "workspace-a");
|
||||
const otherProviderKey = providersSnapshotQueryKey(otherServerId);
|
||||
|
||||
queryClient.setQueryData(providerKey, { entries: [], generatedAt: "now", requestId: "p" });
|
||||
queryClient.setQueryData(daemonConfigKey, daemonConfig);
|
||||
queryClient.setQueryData(pairingOfferKey, { relayEnabled: false, url: "" });
|
||||
queryClient.setQueryData(diffKey, { cwd: "/repo", files: [], error: null, requestId: "d" });
|
||||
queryClient.setQueryData(terminalKey, { cwd: "/repo", terminals: [], requestId: "t" });
|
||||
queryClient.setQueryData(otherProviderKey, {
|
||||
@@ -520,6 +527,7 @@ describe("server data push router", () => {
|
||||
|
||||
expect(queryClient.getQueryState(providerKey)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(daemonConfigKey)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(pairingOfferKey)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(diffKey)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(terminalKey)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(otherProviderKey)?.isInvalidated).toBe(false);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
|
||||
import { providersSnapshotQueryKey, providersSnapshotQueryRoot } from "@/data/providers-snapshot";
|
||||
|
||||
type ProvidersSnapshotUpdateMessage = Extract<
|
||||
@@ -105,6 +106,12 @@ const RECONNECT_REPAIR_POLICIES: ReconnectRepairPolicy[] = [
|
||||
void queryClient.invalidateQueries({ queryKey: daemonConfigQueryKey(serverId) });
|
||||
},
|
||||
},
|
||||
{
|
||||
domain: "daemonPairingOffer",
|
||||
invalidate: ({ queryClient, serverId }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: daemonPairingOfferQueryKey(serverId) });
|
||||
},
|
||||
},
|
||||
{
|
||||
domain: "checkoutDiff",
|
||||
invalidate: ({ queryClient, serverId }) => {
|
||||
@@ -403,6 +410,9 @@ function applyDaemonConfigStatus(input: {
|
||||
daemonConfigQueryKey(input.serverId),
|
||||
payload.config,
|
||||
);
|
||||
void input.queryClient.invalidateQueries({
|
||||
queryKey: daemonPairingOfferQueryKey(input.serverId),
|
||||
});
|
||||
}
|
||||
|
||||
function applyCheckoutDiffUpdate(input: {
|
||||
|
||||
@@ -4,14 +4,15 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
|
||||
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
|
||||
|
||||
export interface PairDeviceModalProps {
|
||||
serverId: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const SNAP_POINTS: string[] = ["82%", "94%"];
|
||||
const SNAP_POINTS: string[] = ["72%", "92%"];
|
||||
|
||||
export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) {
|
||||
export function PairDeviceModal({ serverId, visible, onClose, testID }: PairDeviceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("settings.host.pairDevices.rowTitle") }),
|
||||
@@ -24,10 +25,10 @@ export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalPro
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={SNAP_POINTS}
|
||||
desktopMaxWidth={640}
|
||||
desktopMaxWidth={480}
|
||||
testID={testID}
|
||||
>
|
||||
<PairDeviceSection />
|
||||
<PairDeviceSection serverId={serverId} onClose={onClose} />
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +1,86 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image, Text, TextInput, View } from "react-native";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Text, TextInput, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as QRCode from "qrcode";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { RotateCw, Copy, Check } from "lucide-react-native";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Check, Copy, Network, RotateCw, ShieldCheck } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getDesktopDaemonPairing, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { useState } from "react";
|
||||
import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const RELAY_DOCS_URL = "https://paseo.sh/docs/security";
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedShieldCheck = withUnistyles(ShieldCheck);
|
||||
const ThemedNetwork = withUnistyles(Network);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const accentBrightColorMapping = (theme: Theme) => ({ color: theme.colors.accentBright });
|
||||
|
||||
type PairingViewState =
|
||||
| { tag: "loading" }
|
||||
| { tag: "error"; message: string }
|
||||
| { tag: "unavailable"; message: string }
|
||||
| { tag: "ready"; url: string };
|
||||
|
||||
function resolvePairingViewState(args: {
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
data: { url?: string | null; relayEnabled?: boolean } | undefined;
|
||||
labels: {
|
||||
failedToLoadOffer: string;
|
||||
relayDisabled: string;
|
||||
unavailable: string;
|
||||
};
|
||||
}): PairingViewState {
|
||||
if (args.isPending) return { tag: "loading" };
|
||||
if (args.isError) {
|
||||
const message =
|
||||
args.error instanceof Error ? args.error.message : args.labels.failedToLoadOffer;
|
||||
return { tag: "error", message };
|
||||
}
|
||||
if (!args.data?.url) {
|
||||
const message =
|
||||
args.data?.relayEnabled === false ? args.labels.relayDisabled : args.labels.unavailable;
|
||||
return { tag: "unavailable", message };
|
||||
}
|
||||
return { tag: "ready", url: args.data.url };
|
||||
export interface PairDeviceSectionProps {
|
||||
serverId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PairDeviceSection() {
|
||||
const { theme } = useUnistyles();
|
||||
export function PairDeviceSection({ serverId, onClose }: PairDeviceSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const runtimeSnapshot = useHostRuntimeSnapshot(serverId);
|
||||
const isConnected = runtimeSnapshot?.connectionStatus === "online";
|
||||
const isDisconnected =
|
||||
runtimeSnapshot?.connectionStatus === "offline" ||
|
||||
runtimeSnapshot?.connectionStatus === "error";
|
||||
const { patchConfig } = useDaemonConfig(serverId);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const serverFeatures = client?.getLastServerInfoMessage()?.features;
|
||||
const supportsPairingRpc = serverFeatures?.daemonStatusRpc === true;
|
||||
const canConfigureRelay = supportsPairingRpc && serverFeatures?.relayConfig === true;
|
||||
|
||||
const pairingQuery = useQuery({
|
||||
queryKey: ["desktop-daemon-pairing"],
|
||||
queryFn: getDesktopDaemonPairing,
|
||||
enabled: showSection,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
const pairingQuery = useFetchQuery({
|
||||
queryKey: daemonPairingOfferQueryKey(serverId),
|
||||
queryFn: async () => {
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
return client.getDaemonPairingOffer();
|
||||
},
|
||||
enabled: supportsPairingRpc && Boolean(client && isConnected),
|
||||
dataShape: "value",
|
||||
staleTimeMs: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const qrQuery = useQuery({
|
||||
queryKey: ["desktop-daemon-pairing-qr", pairingQuery.data?.url],
|
||||
const enableRelay = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (client?.getLastServerInfoMessage()?.features?.relayConfig !== true) {
|
||||
throw new Error(t("pairing.device.updateRequired"));
|
||||
}
|
||||
const config = await patchConfig({ relay: { enabled: true } });
|
||||
if (!config) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
return pairingQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const qrQuery = useFetchQuery({
|
||||
queryKey: ["daemon-pairing-offer-qr", pairingQuery.data?.url],
|
||||
queryFn: () =>
|
||||
QRCode.toDataURL(pairingQuery.data!.url!, {
|
||||
QRCode.toString(pairingQuery.data?.url ?? "", {
|
||||
type: "svg",
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 480,
|
||||
}),
|
||||
enabled: !!pairingQuery.data?.url,
|
||||
staleTime: Infinity,
|
||||
enabled: Boolean(pairingQuery.data?.url),
|
||||
dataShape: "value",
|
||||
staleTimeMs: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const handleCopyLink = useCallback(async () => {
|
||||
@@ -81,203 +89,271 @@ export function PairDeviceSection() {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [pairingQuery.data?.url]);
|
||||
|
||||
const handleRefetch = useCallback(() => {
|
||||
void pairingQuery.refetch();
|
||||
}, [pairingQuery]);
|
||||
|
||||
const handleCopyPress = useCallback(() => {
|
||||
void handleCopyLink();
|
||||
}, [handleCopyLink]);
|
||||
const handleRetry = useCallback(() => {
|
||||
void pairingQuery.refetch();
|
||||
}, [pairingQuery]);
|
||||
const handleEnableRelay = useCallback(() => {
|
||||
enableRelay.mutate();
|
||||
}, [enableRelay]);
|
||||
|
||||
const qrImageSource = useMemo(
|
||||
() => (qrQuery.data ? { uri: qrQuery.data } : null),
|
||||
[qrQuery.data],
|
||||
);
|
||||
|
||||
const retryIcon = useMemo(
|
||||
() => <RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />,
|
||||
[theme.iconSize.sm, theme.colors.foreground],
|
||||
);
|
||||
const copyButtonIcon = useMemo(
|
||||
() =>
|
||||
copied ? (
|
||||
<Check size={theme.iconSize.sm} color={theme.colors.accent} />
|
||||
) : (
|
||||
<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
),
|
||||
[copied, theme.iconSize.sm, theme.colors.accent, theme.colors.foreground],
|
||||
);
|
||||
const bodyLabels = useMemo(
|
||||
() => ({
|
||||
loadingOffer: t("pairing.device.loadingOffer"),
|
||||
hint: t("pairing.device.hint"),
|
||||
qrUnavailable: t("pairing.device.qrUnavailable"),
|
||||
retry: t("pairing.device.retry"),
|
||||
copy: t("pairing.device.copy"),
|
||||
copied: t("pairing.device.copied"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!showSection) return null;
|
||||
|
||||
const viewState = resolvePairingViewState({
|
||||
isPending: pairingQuery.isPending,
|
||||
isError: pairingQuery.isError,
|
||||
error: pairingQuery.error,
|
||||
data: pairingQuery.data,
|
||||
labels: {
|
||||
failedToLoadOffer: t("pairing.device.failedToLoadOffer"),
|
||||
relayDisabled: t("pairing.device.relayDisabled"),
|
||||
unavailable: t("pairing.device.unavailable"),
|
||||
},
|
||||
});
|
||||
const qrSvg = useMemo(() => qrQuery.data ?? null, [qrQuery.data]);
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section} testID="host-page-pair-device-card">
|
||||
<View style={settingsStyles.card}>
|
||||
<PairDeviceBody
|
||||
viewState={viewState}
|
||||
theme={theme}
|
||||
retryIcon={retryIcon}
|
||||
copyButtonIcon={copyButtonIcon}
|
||||
qrImageSource={qrImageSource}
|
||||
qrQuery={qrQuery}
|
||||
copied={copied}
|
||||
handleRefetch={handleRefetch}
|
||||
handleCopyPress={handleCopyPress}
|
||||
labels={bodyLabels}
|
||||
/>
|
||||
</View>
|
||||
<View testID="pair-device-content">
|
||||
<PairDeviceBody
|
||||
isPending={supportsPairingRpc && pairingQuery.isPending}
|
||||
isDisconnected={isDisconnected}
|
||||
error={pairingQuery.error}
|
||||
offer={pairingQuery.data}
|
||||
canConfigureRelay={canConfigureRelay}
|
||||
enablePending={enableRelay.isPending}
|
||||
enableError={enableRelay.error}
|
||||
qrSvg={qrSvg}
|
||||
qrError={qrQuery.isError}
|
||||
copied={copied}
|
||||
onRetry={handleRetry}
|
||||
onEnableRelay={handleEnableRelay}
|
||||
onClose={onClose}
|
||||
onCopy={handleCopyPress}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface PairDeviceBodyProps {
|
||||
viewState: PairingViewState;
|
||||
theme: { colors: { accent: string } };
|
||||
retryIcon: React.ReactElement;
|
||||
copyButtonIcon: React.ReactElement;
|
||||
qrImageSource: { uri: string } | null;
|
||||
qrQuery: { isError: boolean };
|
||||
isPending: boolean;
|
||||
isDisconnected: boolean;
|
||||
error: Error | null;
|
||||
offer: { relayEnabled: boolean; url: string } | undefined;
|
||||
canConfigureRelay: boolean;
|
||||
enablePending: boolean;
|
||||
enableError: Error | null;
|
||||
qrSvg: string | null;
|
||||
qrError: boolean;
|
||||
copied: boolean;
|
||||
handleRefetch: () => void;
|
||||
handleCopyPress: () => void;
|
||||
labels: {
|
||||
loadingOffer: string;
|
||||
hint: string;
|
||||
qrUnavailable: string;
|
||||
retry: string;
|
||||
copy: string;
|
||||
copied: string;
|
||||
};
|
||||
onRetry: () => void;
|
||||
onEnableRelay: () => void;
|
||||
onClose: () => void;
|
||||
onCopy: () => void;
|
||||
}
|
||||
|
||||
function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
const {
|
||||
viewState,
|
||||
theme,
|
||||
retryIcon,
|
||||
copyButtonIcon,
|
||||
qrImageSource,
|
||||
qrQuery,
|
||||
copied,
|
||||
handleRefetch,
|
||||
handleCopyPress,
|
||||
labels,
|
||||
} = props;
|
||||
|
||||
if (viewState.tag === "loading") {
|
||||
const { t } = useTranslation();
|
||||
if (props.isDisconnected) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.hint}>{labels.loadingOffer}</Text>
|
||||
</View>
|
||||
<OfferLoadError message={t("workspace.terminal.hostDisconnected")} onRetry={props.onRetry} />
|
||||
);
|
||||
}
|
||||
|
||||
if (viewState.tag === "error" || viewState.tag === "unavailable") {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.hint}>{viewState.message}</Text>
|
||||
<Button variant="outline" size="sm" leftIcon={retryIcon} onPress={handleRefetch}>
|
||||
{labels.retry}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
if (props.isPending) {
|
||||
return <Text style={styles.stateLine}>{t("pairing.device.loadingOffer")}</Text>;
|
||||
}
|
||||
if (props.error) {
|
||||
return <OfferLoadError message={props.error.message} onRetry={props.onRetry} />;
|
||||
}
|
||||
if (!props.offer?.relayEnabled) {
|
||||
return <RelayConsent {...props} />;
|
||||
}
|
||||
if (!props.offer.url) {
|
||||
return <Text style={styles.stateLine}>{t("pairing.device.unavailable")}</Text>;
|
||||
}
|
||||
return <PairingOffer {...props} offer={props.offer} />;
|
||||
}
|
||||
|
||||
function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.hint}>{labels.hint}</Text>
|
||||
<View style={styles.qrContainer}>
|
||||
<PairDeviceQrContent
|
||||
qrImageSource={qrImageSource}
|
||||
qrQuery={qrQuery}
|
||||
unavailableLabel={labels.qrUnavailable}
|
||||
<Alert variant="error" description={message}>
|
||||
<Button variant="outline" size="sm" leftIcon={RotateCw} onPress={onRetry}>
|
||||
{t("pairing.device.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function RelayConsent(props: PairDeviceBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
let enableButtonLabel = t("pairing.device.enableRelay");
|
||||
if (props.enablePending) {
|
||||
enableButtonLabel = t("pairing.device.enablingRelay");
|
||||
} else if (props.enableError) {
|
||||
enableButtonLabel = t("pairing.device.retry");
|
||||
}
|
||||
return (
|
||||
<View style={styles.consent}>
|
||||
<View style={styles.hero}>
|
||||
<RelayHeroBadge />
|
||||
<Text style={styles.consentTitle}>{t("pairing.device.enableTitle")}</Text>
|
||||
<Text style={styles.consentDescription}>{t("pairing.device.enableDescription")}</Text>
|
||||
<ExternalLink
|
||||
href={RELAY_DOCS_URL}
|
||||
label={t("pairing.device.relayDocs")}
|
||||
accessibilityLabel={t("pairing.device.relayDocsAccessibility")}
|
||||
/>
|
||||
</View>
|
||||
{props.enableError ? <Alert variant="error" description={props.enableError.message} /> : null}
|
||||
{!props.canConfigureRelay ? (
|
||||
<Alert variant="warning" description={t("pairing.device.updateRequired")} />
|
||||
) : null}
|
||||
<View style={styles.actions}>
|
||||
<Button variant="secondary" style={FLEX_ONE_STYLE} onPress={props.onClose}>
|
||||
{t("pairing.device.notNow")}
|
||||
</Button>
|
||||
{props.canConfigureRelay ? (
|
||||
<Button
|
||||
variant="default"
|
||||
style={FLEX_ONE_STYLE}
|
||||
loading={props.enablePending}
|
||||
onPress={props.onEnableRelay}
|
||||
>
|
||||
{enableButtonLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.directRow}>
|
||||
<ThemedNetwork size={14} style={styles.directIcon} />
|
||||
<Text style={styles.directHint}>{t("pairing.device.directConnectionHint")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RelayHeroBadge() {
|
||||
return (
|
||||
<View style={styles.heroBadge}>
|
||||
<ThemedShieldCheck size={20} uniProps={accentBrightColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.offer}>
|
||||
<Text style={styles.offerHint}>{t("pairing.device.hint")}</Text>
|
||||
<View style={styles.qrTile}>
|
||||
<PairingQr svg={props.qrSvg} isError={props.qrError} />
|
||||
</View>
|
||||
<View style={styles.linkRow}>
|
||||
<View style={styles.inputWrapper}>
|
||||
<TextInput
|
||||
style={styles.linkInput}
|
||||
value={viewState.url}
|
||||
value={props.offer.url}
|
||||
readOnly
|
||||
selectTextOnFocus
|
||||
selectionColor={theme.colors.accent}
|
||||
accessibilityLabel={t("pairing.link.label")}
|
||||
/>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" leftIcon={copyButtonIcon} onPress={handleCopyPress}>
|
||||
{copied ? labels.copied : labels.copy}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={props.copied ? Check : Copy}
|
||||
onPress={props.onCopy}
|
||||
>
|
||||
{props.copied ? t("pairing.device.copied") : t("pairing.device.copy")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PairDeviceQrContent(props: {
|
||||
qrImageSource: { uri: string } | null;
|
||||
qrQuery: { isError: boolean };
|
||||
unavailableLabel: string;
|
||||
}) {
|
||||
if (props.qrImageSource) {
|
||||
return <Image source={props.qrImageSource} style={styles.qrImage} resizeMode="contain" />;
|
||||
function PairingQr({ svg, isError }: { svg: string | null; isError: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (svg) {
|
||||
return (
|
||||
<SvgXml
|
||||
xml={svg}
|
||||
style={styles.qrImage}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={t("pairing.device.qrAccessibility")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (props.qrQuery.isError) {
|
||||
return <Text style={styles.hint}>{props.unavailableLabel}</Text>;
|
||||
if (isError) {
|
||||
return <Text style={styles.hint}>{t("pairing.device.qrUnavailable")}</Text>;
|
||||
}
|
||||
return <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
centered: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[6],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
content: {
|
||||
gap: theme.spacing[3],
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
hint: {
|
||||
stateLine: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
paddingVertical: theme.spacing[6],
|
||||
},
|
||||
qrContainer: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignSelf: "center",
|
||||
width: 320,
|
||||
height: 320,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
consent: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
hero: {
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
heroBadge: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[2],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
consentTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
consentDescription: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.5,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
directRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
paddingTop: theme.spacing[4],
|
||||
},
|
||||
directIcon: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginTop: 1, // optical: seats the glyph on the hint's first text line
|
||||
},
|
||||
directHint: {
|
||||
flex: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.5,
|
||||
},
|
||||
offer: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
offerHint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
qrTile: {
|
||||
alignSelf: "center",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 304,
|
||||
maxWidth: "100%",
|
||||
aspectRatio: 1,
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.palette.white,
|
||||
},
|
||||
qrImage: {
|
||||
width: "100%",
|
||||
@@ -290,17 +366,22 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
inputWrapper: {
|
||||
flex: 1,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
overflow: "hidden",
|
||||
},
|
||||
linkInput: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
outlineStyle: "none",
|
||||
} as object,
|
||||
hint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -40,12 +40,6 @@ export interface DesktopAppLogs {
|
||||
contents: string;
|
||||
}
|
||||
|
||||
export interface DesktopPairingOffer {
|
||||
relayEnabled: boolean;
|
||||
url: string | null;
|
||||
qr: string | null;
|
||||
}
|
||||
|
||||
export interface LocalTransportTarget {
|
||||
[key: string]: unknown;
|
||||
transportType: "socket" | "pipe";
|
||||
@@ -119,17 +113,6 @@ function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs {
|
||||
};
|
||||
}
|
||||
|
||||
function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected desktop daemon pairing response.");
|
||||
}
|
||||
return {
|
||||
relayEnabled: raw.relayEnabled === true,
|
||||
url: toStringOrNull(raw.url),
|
||||
qr: toStringOrNull(raw.qr),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldUseDesktopDaemon(): boolean {
|
||||
return isElectronRuntime();
|
||||
}
|
||||
@@ -167,10 +150,6 @@ export async function getDesktopAppLogs(): Promise<DesktopAppLogs> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
|
||||
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
|
||||
}
|
||||
|
||||
export async function getCliDaemonStatus(): Promise<string> {
|
||||
const raw = await invokeDesktopCommand<unknown>("cli_daemon_status");
|
||||
if (typeof raw !== "string") {
|
||||
|
||||
@@ -1422,9 +1422,21 @@ export const ar: TranslationResources = {
|
||||
loadingOffer: "جارٍ تحميل عرض الإقران...",
|
||||
failedToLoadOffer: "فشل تحميل عرض الاقتران.",
|
||||
relayDisabled: "لم يتم تمكين التتابع. تمكين التتابع لإقران جهاز.",
|
||||
enableTitle: "تمكين التتابع؟",
|
||||
enableDescription:
|
||||
"يتيح التتابع لهذا الجهاز الاتصال من أي مكان. حركة مرور الإقران مشفرة من طرف إلى طرف.",
|
||||
relayDocs: "كيفية عمل التتابع",
|
||||
relayDocsAccessibility: "اقرأ كيفية عمل تتابع Paseo",
|
||||
enableRelay: "تمكين التتابع",
|
||||
enablingRelay: "جارٍ التمكين...",
|
||||
notNow: "ليس الآن",
|
||||
directConnectionHint:
|
||||
"بدون التتابع، اتصل مباشرة عبر TCP أو Tailscale أو شبكة VPN أخرى. لن يتم إنشاء رمز QR.",
|
||||
updateRequired: "حدّث المضيف لتمكين التتابع من Paseo Desktop.",
|
||||
unavailable: "عرض الاقتران غير متاح.",
|
||||
hint: "قم بمسح رمز QR هذا باستخدام Paseo على هاتفك، أو انسخ الرابط أدناه.",
|
||||
qrUnavailable: "رمز QR غير متاح.",
|
||||
qrAccessibility: "رمز QR للإقران",
|
||||
retry: "أعد المحاولة",
|
||||
copy: "ينسخ",
|
||||
copied: "منقول",
|
||||
|
||||
@@ -1433,9 +1433,21 @@ export const en = {
|
||||
loadingOffer: "Loading pairing offer...",
|
||||
failedToLoadOffer: "Failed to load pairing offer.",
|
||||
relayDisabled: "Relay is not enabled. Enable relay to pair a device.",
|
||||
enableTitle: "Enable relay?",
|
||||
enableDescription:
|
||||
"Relay lets this device connect from anywhere. Pairing traffic is end-to-end encrypted.",
|
||||
relayDocs: "How relay works",
|
||||
relayDocsAccessibility: "Read how Paseo relay works",
|
||||
enableRelay: "Enable relay",
|
||||
enablingRelay: "Enabling...",
|
||||
notNow: "Not now",
|
||||
directConnectionHint:
|
||||
"Without relay, connect directly over TCP, Tailscale, or another VPN. No QR code is created.",
|
||||
updateRequired: "Update the host to enable relay from Paseo Desktop.",
|
||||
unavailable: "Pairing offer unavailable.",
|
||||
hint: "Scan this QR code with Paseo on your phone, or copy the link below.",
|
||||
qrUnavailable: "QR code unavailable.",
|
||||
qrAccessibility: "Pairing QR code",
|
||||
retry: "Retry",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
|
||||
@@ -1465,9 +1465,21 @@ export const es: TranslationResources = {
|
||||
loadingOffer: "Cargando oferta de maridaje...",
|
||||
failedToLoadOffer: "No se pudo cargar la oferta de emparejamiento.",
|
||||
relayDisabled: "El relé no está habilitado. Habilite el relé para emparejar un dispositivo.",
|
||||
enableTitle: "¿Habilitar el relé?",
|
||||
enableDescription:
|
||||
"El relé permite conectar este dispositivo desde cualquier lugar. El tráfico de emparejamiento está cifrado de extremo a extremo.",
|
||||
relayDocs: "Cómo funciona el relé",
|
||||
relayDocsAccessibility: "Leer cómo funciona el relé de Paseo",
|
||||
enableRelay: "Habilitar relé",
|
||||
enablingRelay: "Habilitando...",
|
||||
notNow: "Ahora no",
|
||||
directConnectionHint:
|
||||
"Sin relé, conéctese directamente por TCP, Tailscale u otra VPN. No se crea ningún código QR.",
|
||||
updateRequired: "Actualice el host para habilitar el relé desde Paseo Desktop.",
|
||||
unavailable: "Oferta de maridaje no disponible.",
|
||||
hint: "Escanee este códigoQRconPaseoen su teléfono o copie el enlace a continuación.",
|
||||
qrUnavailable: "CódigoQRno disponible.",
|
||||
qrAccessibility: "Código QR de emparejamiento",
|
||||
retry: "Rever",
|
||||
copy: "Copiar",
|
||||
copied: "Copiado",
|
||||
|
||||
@@ -1469,9 +1469,21 @@ export const fr: TranslationResources = {
|
||||
loadingOffer: "Chargement de l'offre d'association...",
|
||||
failedToLoadOffer: "Échec du chargement de l'offre d'association.",
|
||||
relayDisabled: "Le relais n'est pas activé. Activer le relais pour coupler un appareil.",
|
||||
enableTitle: "Activer le relais ?",
|
||||
enableDescription:
|
||||
"Le relais permet à cet appareil de se connecter depuis n’importe où. Le trafic de couplage est chiffré de bout en bout.",
|
||||
relayDocs: "Fonctionnement du relais",
|
||||
relayDocsAccessibility: "Lire comment fonctionne le relais Paseo",
|
||||
enableRelay: "Activer le relais",
|
||||
enablingRelay: "Activation...",
|
||||
notNow: "Pas maintenant",
|
||||
directConnectionHint:
|
||||
"Sans relais, connectez-vous directement via TCP, Tailscale ou un autre VPN. Aucun code QR n’est créé.",
|
||||
updateRequired: "Mettez à jour l’hôte pour activer le relais depuis Paseo Desktop.",
|
||||
unavailable: "Offre de jumelage indisponible.",
|
||||
hint: "Scannez ce codeQRavecPaseosur votre téléphone ou copiez le lien ci-dessous.",
|
||||
qrUnavailable: "CodeQRindisponible.",
|
||||
qrAccessibility: "Code QR de couplage",
|
||||
retry: "Réessayer",
|
||||
copy: "Copie",
|
||||
copied: "Copié",
|
||||
|
||||
@@ -1438,9 +1438,21 @@ export const ja: TranslationResources = {
|
||||
failedToLoadOffer: "ペアリングオファーの読み込みに失敗しました。",
|
||||
relayDisabled:
|
||||
"リレーが有効になっていません。デバイスをペアリングするにはリレーを有効にしてください。",
|
||||
enableTitle: "リレーを有効にしますか?",
|
||||
enableDescription:
|
||||
"リレーを使うと、このデバイスからどこでも接続できます。ペアリング通信はエンドツーエンドで暗号化されます。",
|
||||
relayDocs: "リレーの仕組み",
|
||||
relayDocsAccessibility: "Paseo リレーの仕組みを読む",
|
||||
enableRelay: "リレーを有効にする",
|
||||
enablingRelay: "有効化中...",
|
||||
notNow: "今はしない",
|
||||
directConnectionHint:
|
||||
"リレーを使わない場合は、TCP、Tailscale、または別の VPN で直接接続してください。QR コードは作成されません。",
|
||||
updateRequired: "Paseo Desktop からリレーを有効にするにはホストを更新してください。",
|
||||
unavailable: "ペアリングオファーが利用できません。",
|
||||
hint: "スマートフォンのPaseoでこのQRコードをスキャンするか、以下のリンクをコピーしてください。",
|
||||
qrUnavailable: "QRコードが利用できません。",
|
||||
qrAccessibility: "ペアリング QR コード",
|
||||
retry: "再試行",
|
||||
copy: "コピー",
|
||||
copied: "コピーしました",
|
||||
|
||||
@@ -1451,9 +1451,21 @@ export const ptBR: TranslationResources = {
|
||||
loadingOffer: "Carregando oferta de pareamento...",
|
||||
failedToLoadOffer: "Falha ao carregar oferta de pareamento.",
|
||||
relayDisabled: "Relay não está ativado. Ative o relay para parear um dispositivo.",
|
||||
enableTitle: "Ativar o relay?",
|
||||
enableDescription:
|
||||
"O relay permite conectar este dispositivo de qualquer lugar. O tráfego de pareamento é criptografado de ponta a ponta.",
|
||||
relayDocs: "Como o relay funciona",
|
||||
relayDocsAccessibility: "Leia como o relay do Paseo funciona",
|
||||
enableRelay: "Ativar relay",
|
||||
enablingRelay: "Ativando...",
|
||||
notNow: "Agora não",
|
||||
directConnectionHint:
|
||||
"Sem relay, conecte diretamente por TCP, Tailscale ou outra VPN. Nenhum código QR é criado.",
|
||||
updateRequired: "Atualize o host para ativar o relay pelo Paseo Desktop.",
|
||||
unavailable: "Oferta de pareamento indisponível.",
|
||||
hint: "Escaneie este QR code com o Paseo no seu celular ou copie o link abaixo.",
|
||||
qrUnavailable: "QR code indisponível.",
|
||||
qrAccessibility: "Código QR de pareamento",
|
||||
retry: "Tentar novamente",
|
||||
copy: "Copiar",
|
||||
copied: "Copiado",
|
||||
|
||||
@@ -1456,9 +1456,21 @@ export const ru: TranslationResources = {
|
||||
loadingOffer: "Загрузка предложения по сопряжению...",
|
||||
failedToLoadOffer: "Не удалось загрузить предложение сопряжения.",
|
||||
relayDisabled: "Реле не включено. Включите реле для сопряжения устройства.",
|
||||
enableTitle: "Включить реле?",
|
||||
enableDescription:
|
||||
"Реле позволяет подключаться с этого устройства откуда угодно. Трафик сопряжения защищён сквозным шифрованием.",
|
||||
relayDocs: "Как работает реле",
|
||||
relayDocsAccessibility: "Прочитать, как работает реле Paseo",
|
||||
enableRelay: "Включить реле",
|
||||
enablingRelay: "Включение...",
|
||||
notNow: "Не сейчас",
|
||||
directConnectionHint:
|
||||
"Без реле подключайтесь напрямую через TCP, Tailscale или другую VPN. QR-код не создаётся.",
|
||||
updateRequired: "Обновите хост, чтобы включить реле из Paseo Desktop.",
|
||||
unavailable: "Предложение по сопряжению недоступно.",
|
||||
hint: "Отсканируйте этот код QR с помощью Paseo на своем телефоне или скопируйте ссылку ниже.",
|
||||
qrUnavailable: "Код QR недоступен.",
|
||||
qrAccessibility: "QR-код сопряжения",
|
||||
retry: "Повторить попытку",
|
||||
copy: "Копировать",
|
||||
copied: "Скопировано",
|
||||
|
||||
@@ -1403,9 +1403,20 @@ export const zhCN: TranslationResources = {
|
||||
loadingOffer: "正在加载配对 offer...",
|
||||
failedToLoadOffer: "加载配对 offer 失败。",
|
||||
relayDisabled: "Relay 未启用。启用 relay 后才能配对设备。",
|
||||
enableTitle: "启用中继?",
|
||||
enableDescription: "中继让此设备可以从任何地方连接。配对流量采用端到端加密。",
|
||||
relayDocs: "中继如何工作",
|
||||
relayDocsAccessibility: "阅读 Paseo 中继的工作原理",
|
||||
enableRelay: "启用中继",
|
||||
enablingRelay: "正在启用...",
|
||||
notNow: "暂不",
|
||||
directConnectionHint:
|
||||
"不使用中继时,请通过 TCP、Tailscale 或其他 VPN 直接连接。不会生成二维码。",
|
||||
updateRequired: "请更新主机,以便从 Paseo Desktop 启用中继。",
|
||||
unavailable: "配对 offer 不可用。",
|
||||
hint: "用手机上的 Paseo 扫描此二维码,或复制下方链接。",
|
||||
qrUnavailable: "二维码不可用。",
|
||||
qrAccessibility: "配对二维码",
|
||||
retry: "重试",
|
||||
copy: "复制",
|
||||
copied: "已复制",
|
||||
|
||||
@@ -132,6 +132,7 @@ export function OpenProjectScreen() {
|
||||
<CommunityLinks />
|
||||
</View>
|
||||
<PairDeviceModal
|
||||
serverId={localServerId ?? ""}
|
||||
visible={isPairDeviceOpen}
|
||||
onClose={handleClosePairDevice}
|
||||
testID="open-project-pair-device-modal"
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
FolderGit2,
|
||||
SquareTerminal,
|
||||
Code2,
|
||||
Smartphone,
|
||||
} from "lucide-react-native";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
@@ -95,6 +96,7 @@ import {
|
||||
} from "@/i18n/locales";
|
||||
import {
|
||||
HostConnectionsPage,
|
||||
HostPairDevicePage,
|
||||
HostAgentsPage,
|
||||
HostSettingsPage,
|
||||
HostProvidersPage,
|
||||
@@ -167,6 +169,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
|
||||
{ id: "host", labelKey: "settings.hostSections.host", icon: Server },
|
||||
{ id: "projects", labelKey: "settings.hostSections.projects", icon: FolderGit2 },
|
||||
{ id: "connections", labelKey: "settings.hostSections.connections", icon: Network },
|
||||
{ id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone },
|
||||
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
|
||||
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
|
||||
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
|
||||
@@ -183,6 +186,8 @@ function renderHostSettingsContent(
|
||||
return <ProjectsScreen serverId={view.serverId} />;
|
||||
case "connections":
|
||||
return <HostConnectionsPage serverId={view.serverId} />;
|
||||
case "pair-device":
|
||||
return <HostPairDevicePage serverId={view.serverId} />;
|
||||
case "agents":
|
||||
return <HostAgentsPage serverId={view.serverId} />;
|
||||
case "workspaces":
|
||||
@@ -1262,8 +1267,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
if (view.kind !== "host") {
|
||||
return;
|
||||
}
|
||||
const section: HostSectionSlug = view.section;
|
||||
const target = buildSettingsHostSectionRoute(serverId, section);
|
||||
const target = buildSettingsHostSectionRoute(serverId, view.section);
|
||||
if (isCompactLayout) {
|
||||
router.push(target);
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
function makeConfig(browserToolsEnabled = false): MutableDaemonConfig {
|
||||
return {
|
||||
relay: { enabled: false },
|
||||
mcp: { injectIntoAgents: false },
|
||||
browserTools: { enabled: browserToolsEnabled },
|
||||
providers: {},
|
||||
|
||||
@@ -237,9 +237,7 @@ function HostConnectionError({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
export function HostConnectionsPage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const host = useHostProfile(serverId);
|
||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||
|
||||
if (!host) {
|
||||
return <HostNotFound />;
|
||||
@@ -249,15 +247,25 @@ export function HostConnectionsPage({ serverId }: { serverId: string }) {
|
||||
<View>
|
||||
<HostConnectionError serverId={serverId} />
|
||||
<ConnectionsSection host={host} />
|
||||
{isLocalDaemon ? (
|
||||
<SettingsSection title={t("settings.host.pairDevices.title")}>
|
||||
<PairDeviceRow />
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostPairDevicePage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const host = useHostProfile(serverId);
|
||||
|
||||
if (!host) {
|
||||
return <HostNotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("settings.host.pairDevices.title")}>
|
||||
<PairDeviceRow serverId={serverId} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostAgentsPage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const host = useHostProfile(serverId);
|
||||
@@ -1241,7 +1249,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PairDeviceRow() {
|
||||
function PairDeviceRow({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -1265,6 +1273,7 @@ function PairDeviceRow() {
|
||||
</Pressable>
|
||||
|
||||
<PairDeviceModal
|
||||
serverId={serverId}
|
||||
visible={isModalOpen}
|
||||
onClose={handleClose}
|
||||
testID="host-page-pair-device-card"
|
||||
|
||||
@@ -304,6 +304,7 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
|
||||
|
||||
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
|
||||
return {
|
||||
relay: { enabled: false },
|
||||
mcp: { injectIntoAgents: false },
|
||||
browserTools: { enabled: false },
|
||||
providers,
|
||||
|
||||
@@ -246,6 +246,7 @@ describe("global routes", () => {
|
||||
describe("host settings section slugs", () => {
|
||||
it("keeps current host settings sections", () => {
|
||||
expect(normalizeHostSectionSlug("connections")).toBe("connections");
|
||||
expect(normalizeHostSectionSlug("pair-device")).toBe("pair-device");
|
||||
expect(normalizeHostSectionSlug("agents")).toBe("agents");
|
||||
expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces");
|
||||
expect(normalizeHostSectionSlug("projects")).toBe("projects");
|
||||
|
||||
@@ -509,6 +509,7 @@ export function isSettingsSectionSlug(value: string): value is SettingsSectionSl
|
||||
export const HOST_SECTION_SLUGS = [
|
||||
"projects",
|
||||
"connections",
|
||||
"pair-device",
|
||||
"agents",
|
||||
"workspaces",
|
||||
"providers",
|
||||
|
||||
Reference in New Issue
Block a user