Compare commits

...
Author SHA1 Message Date
Mohamed Boudra cbbeed88ae test(app): verify pairing sheet autofocus 2026-09-30 17:12:23 +02:00
Mohamed Boudra 6353c8e4b8 fix(app): stack adaptive sheets across native presentations
Root-owned dialogs could not present over a sibling native dialog on iOS. Use the existing app-wide sheet stack for both native presentation forms, and release the underlying keyboard when a new sheet opens.
2026-09-30 15:34:38 +02:00
Mohamed Boudra bddc9a5bd5 Stop QR scanning after an attempt ends
The scan screen now keeps its workflow in a small model. When the user
cancels the host confirmation, closes the password prompt, or the attempt
fails, scanning stops until they tap Scan QR code again. Before, the code
still in front of the camera started a new attempt on the next frame.

A host confirmation answer is now tied to the request it answers, so a tap
on one sheet never answers a newer one. Relay hosts can no longer be added
through HostMutations without the confirmation, the add-host modal drops
the reset its per-open mount made redundant, and the fingerprint comment
says what the shortened form shows.
2026-09-30 13:02:26 +02:00
Mohamed Boudra 1f8ce0ee08 Move host confirmation into the host runtime
The host runtime now owns the pending host confirmation and the decision to
ask. The sheet reads the pending confirmation and sends the answer; nothing
registers into a module variable.

A pairing flow is a LinkPairing the runtime hands out, so the password retry
for a confirmed link doesn't ask again without views creating or resetting
approval objects. Cancel is a result instead of an error. Pairing waits for
saved hosts to load before deciding whether to ask.
2026-09-30 12:38:04 +02:00
Mohamed Boudra cbf3bd5a80 Only ask for new hosts or changed connections
A pairing link now connects without the confirmation when it matches a
saved host exactly (same server id, key and relay). It still asks for a new
host, and for a saved host whose key or relay differs from what is saved,
with the "This link changes how you connect to this host" text.

After the user approves a link that then needs a password, the password
retry no longer asks again. The approval is an `OfferApproval` owned by one
pairing flow (one QR scan, or one open pair-link or add-host modal) and
only covers the exact offer that was approved.

Adds tests for a matching saved host, a changed key or relay, and the
password retry.
2026-09-30 12:14:27 +02:00
Mohamed Boudra fd7130ad44 Add confirmation for new hosts
Opening a pairing link (an `#offer=` URL, a scanned QR code, or a pasted
link) now asks the user to confirm before the host is added or connected.
The prompt shows the host id, its key fingerprint, and the relay. Cancel
leaves no new host and no connection.

The host runtime owns the check: `upsertConnectionFromOfferUrl` and
`probeAndUpsertConnectionFromOfferUrl` ask a registered confirmer before
doing anything. `HostTrustPromptModal`, mounted at the app root, registers
the confirmer and renders the prompt with the shared confirm sheet. Without
a registered confirmer, pairing links are declined.

Adds a test that an offer link with the confirmation declined adds no host
and attempts no connection.
2026-09-30 12:04:39 +02:00
26 changed files with 1404 additions and 317 deletions
@@ -0,0 +1,51 @@
import { test, expect } from "../support/fixtures";
import { openSettings } from "../support/helpers/app";
import { expectAppRoute } from "../support/helpers/route-assertions";
import {
buildPairingLink,
cancelHostConfirmation,
connectToConfirmedHost,
expectHostConfirmationFor,
expectHostInHostPicker,
hostConfirmation,
openPairingLink,
saveLinkedHostBeforeLoad,
type LinkedHost,
} from "../support/helpers/host-confirmation";
// An unreachable relay: the link saves the host without connecting first.
const linkedHost: LinkedHost = {
serverId: "srv_link_confirmation",
relayEndpoint: "127.0.0.1:59998",
daemonPublicKeyB64: "bGlua0NvbmZpcm1hdGlvbktleQ",
};
test("a pairing link for a new host asks before saving it", async ({ page }) => {
const link = buildPairingLink(linkedHost);
await test.step("Cancel saves nothing and stays on the page", async () => {
await openPairingLink(page, link);
await expectHostConfirmationFor(page, linkedHost);
await cancelHostConfirmation(page);
await expectAppRoute(page, "/settings/general");
await expectHostInHostPicker(page, linkedHost.serverId, "not listed");
});
await test.step("Connect saves the host and opens a project", async () => {
await openPairingLink(page, link);
await expectHostConfirmationFor(page, linkedHost);
await connectToConfirmedHost(page);
await expectAppRoute(page, "/open-project");
await openSettings(page);
await expectHostInHostPicker(page, linkedHost.serverId, "listed");
});
});
test("a pairing link for a saved host connects without asking", async ({ page }) => {
await saveLinkedHostBeforeLoad(page, linkedHost);
await openPairingLink(page, buildPairingLink(linkedHost));
await expectAppRoute(page, "/open-project", { timeout: 30_000 });
await expect(hostConfirmation(page)).toHaveCount(0);
});
@@ -90,3 +90,48 @@ a failed provider tap leaves its modal open, so dismiss it before the next run.
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
Keep generated screenshots, recordings, and run logs outside the repository.
## Stacked adaptive sheets
`stacked-pairing.ad` opens the welcome screen's pairing sheet and then the root-mounted host
confirmation. It first types without tapping the input to verify autofocus, then cancels the
confirmation, closes and reopens the underlying sheet, confirms again,
and completes the password retry. `stacked-settings.ad` checks the same return and retry through
Settings → add host → Direct connection → Advanced. Run both on phones and tablets so both the
bottom sheet and centered dialog are exercised.
Use a fresh English app with notification permission granted and first-use keyboard prompts
already dismissed. Load this checkout from an isolated Metro. Start an isolated password-protected
daemon and a local Wrangler relay with `--var 'PASEO_RELAY_UPSTREAM:'`; the checked-in upstream
setting otherwise forwards traffic to the hosted relay, which drops password rejection replies.
Set `PAIRING_LINK` to the daemon's `relay://` URI with an intentionally incorrect `password` query
parameter, and `PASSWORD` to its correct password. This exercises password retry even while the
daemon accepts relay connections with no credential.
```sh
agent-device replay packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad \
--state-dir "$SHEET_QA_DEVICE_STATE" --session stacked-ipad --platform ios \
--udid "$SHEET_QA_IOS_UDID" \
--env PAIRING_LINK="$SHEET_QA_LINK" --env PASSWORD="$SHEET_QA_PASSWORD" \
--env OUT="$SHEET_QA_OUTPUT"
```
For Settings, first open Settings with Add host visible. With a saved host, open Switch host
first. Use a second loopback spelling
(`localhost` instead of `127.0.0.1`) for the same local relay so the saved host asks for confirmation
of its changed connection. Pass that link to `stacked-settings.ad` with the same arguments.
For an app with no saved hosts, use the original link; Add host is visible directly in Settings.
Android uses the same journeys: copy each script outside the repository, change its context's
`platform=ios` to `platform=android`, and pass `--serial` instead of `--udid`. Reverse both Metro
(including the port advertised by its manifest) and the local relay ports. Keep driver state,
artifacts, and sessions separate from other runs. These host-dependent journeys remain outside the
default mobile suite, alongside the existing modal-sheet scripts.
On iPhone, bulk replacement or bulk keyboard entry of the advanced URI field triggers a
repeated-update error with both the parent and stacked-sheet implementations. Entering the complete
URI one character at a time passed with both implementations; faster typing remains unverified.
On iPad, replacing the field a second time appends text. For this Settings journey, use the
native clipboard Paste menu, then continue the assertions manually. Dismiss the password keyboard
with Done to submit when the Connect button is below the keyboard. The welcome pairing regression
runs end to end without these adjustments.
@@ -0,0 +1,50 @@
# Fresh English app at Welcome, local relay host with PASSWORD, see README.md.
# Run on phones and tablets: the confirmation lives at the root, outside the pairing sheet.
context platform=ios timeout=180000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"welcome-paste-pairing-link\"" 20000
screenshot "${OUT}/01-welcome.png"
press "id=\"welcome-paste-pairing-link\""
wait stable
# Type without tapping the input: opening the sheet must retain its autofocus.
wait "id=\"pair-link-input\"" 10000
type "relay://"
wait "id=\"pair-link-input\" value=\"relay://\"" 5000
screenshot "${OUT}/20-pairing-autofocus-without-tap.png"
fill "id=\"pair-link-input\"" "${PAIRING_LINK}"
wait stable
screenshot "${OUT}/02-pairing-input.png"
press "id=\"pair-link-submit\""
wait stable
wait "id=\"host-confirmation-connect\"" 10000
screenshot "${OUT}/03-confirmation-on-top.png"
press "id=\"host-confirmation-cancel\""
wait stable
wait "id=\"pair-link-submit\" enabled=true" 10000
screenshot "${OUT}/04-cancel-returns-to-pairing.png"
# Close and reopen the underlying sheet, then edit it and submit again.
press "id=\"pair-link-cancel\""
wait stable
wait "id=\"welcome-paste-pairing-link\"" 10000
screenshot "${OUT}/05-underlying-sheet-closes.png"
press "id=\"welcome-paste-pairing-link\""
wait stable
wait "id=\"pair-link-input\"" 10000
fill "id=\"pair-link-input\"" "${PAIRING_LINK}"
wait stable
press "id=\"pair-link-submit\""
wait stable
wait "id=\"host-confirmation-connect\"" 10000
screenshot "${OUT}/06-confirmation-reopens.png"
press "id=\"host-confirmation-connect\""
wait stable
wait "id=\"pair-link-password-input\"" 20000
screenshot "${OUT}/07-password-after-connect.png"
fill "id=\"pair-link-password-input\"" "${PASSWORD}"
wait stable
press "id=\"pair-link-submit\""
wait stable
wait text "Open a folder on your machine" 30000
screenshot "${OUT}/08-connected.png"
@@ -0,0 +1,46 @@
# Settings with Add host visible: no saved hosts, or the host picker already open.
context platform=ios timeout=180000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"settings-add-host\"" 20000
screenshot "${OUT}/09-settings.png"
press "id=\"settings-add-host\""
wait stable
wait "id=\"add-host-method-direct\"" 10000
screenshot "${OUT}/10-add-host-methods.png"
press "id=\"add-host-method-direct\""
wait stable
press "id=\"direct-host-advanced-toggle\""
wait stable
fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}"
wait stable
screenshot "${OUT}/11-add-host-relay-input.png"
press "id=\"direct-host-submit\""
wait stable
wait "id=\"host-confirmation-connect\"" 10000
screenshot "${OUT}/12-add-host-confirmation-on-top.png"
press "id=\"host-confirmation-cancel\""
wait stable
wait "id=\"direct-host-submit\" enabled=true" 10000
screenshot "${OUT}/13-cancel-returns-to-add-host.png"
# Editing the underlying sheet proves it still accepts input after closing the top sheet.
fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}"
wait stable
press "id=\"direct-host-submit\""
wait stable
wait "id=\"host-confirmation-connect\"" 10000
screenshot "${OUT}/14-add-host-confirmation-reopens.png"
press "id=\"host-confirmation-connect\""
wait stable
wait "id=\"direct-password-input\"" 20000
screenshot "${OUT}/15-add-host-password.png"
fill "id=\"direct-password-input\"" "${PASSWORD}"
wait stable
press "id=\"direct-host-submit\""
wait stable
open "${APP_ID}" "paseo://settings"
wait "label=\"Connections\"" 10000
press "label=\"Connections\""
wait stable
wait "id=\"host-page-connections-card\"" 30000
screenshot "${OUT}/16-settings-connected.png"
@@ -0,0 +1,95 @@
import { expect, type Locator, type Page } from "@playwright/test";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
export interface LinkedHost {
serverId: string;
relayEndpoint: string;
daemonPublicKeyB64: string;
}
/**
* A pairing link that opens on the settings page. `/` redirects to
* `/open-project` on its own, so starting in settings leaves the pairing link
* as the only thing that can move the app to `/open-project`.
*/
export function buildPairingLink(host: LinkedHost): string {
const offer = {
v: 2,
serverId: host.serverId,
daemonPublicKeyB64: host.daemonPublicKeyB64,
relay: { endpoint: host.relayEndpoint, useTls: false },
};
const encoded = Buffer.from(JSON.stringify(offer), "utf8").toString("base64url");
return `/settings/general#offer=${encoded}`;
}
/** Loads the app from a pairing link, the way opening the link in a browser does. */
export async function openPairingLink(page: Page, link: string): Promise<void> {
// Leave the link's page first so the link always loads the app from scratch.
await page.goto("/");
await page.goto(link);
}
/** Saves a relay host before the app loads, as if it had been paired in an earlier session. */
export async function saveLinkedHostBeforeLoad(page: Page, host: LinkedHost): Promise<void> {
await page.goto("/");
const nowIso = new Date().toISOString();
const connectionId = `relay:${host.relayEndpoint}`;
const savedHost = {
serverId: host.serverId,
label: host.serverId,
connections: [
{
id: connectionId,
type: "relay",
relayEndpoint: host.relayEndpoint,
useTls: false,
daemonPublicKeyB64: host.daemonPublicKeyB64,
},
],
preferredConnectionId: connectionId,
createdAt: nowIso,
updatedAt: nowIso,
};
await page.evaluate(({ key, hosts }) => localStorage.setItem(key, JSON.stringify(hosts)), {
key: EXTRA_HOSTS_KEY,
hosts: [savedHost],
});
}
export function hostConfirmation(page: Page): Locator {
return page.getByTestId("host-confirmation");
}
export async function expectHostConfirmationFor(page: Page, host: LinkedHost): Promise<void> {
const sheet = hostConfirmation(page);
await expect(sheet.getByText("Connect to this host?", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(sheet.getByTestId("host-confirmation-server-id")).toHaveText(host.serverId);
await expect(sheet.getByTestId("host-confirmation-relay")).toHaveText(host.relayEndpoint);
}
export async function cancelHostConfirmation(page: Page): Promise<void> {
await hostConfirmation(page).getByRole("button", { name: "Cancel", exact: true }).click();
await expect(hostConfirmation(page)).toHaveCount(0);
}
export async function connectToConfirmedHost(page: Page): Promise<void> {
await hostConfirmation(page).getByRole("button", { name: "Connect", exact: true }).click();
await expect(hostConfirmation(page)).toHaveCount(0);
}
/** Checks the host picker on the settings page the app is already showing. */
export async function expectHostInHostPicker(
page: Page,
serverId: string,
expected: "listed" | "not listed",
): Promise<void> {
await page.getByTestId("settings-host-picker").click();
await expect(page.getByTestId(`settings-host-picker-item-${serverId}`)).toHaveCount(
expected === "listed" ? 1 : 0,
);
await page.keyboard.press("Escape");
}
+2
View File
@@ -45,6 +45,7 @@ import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
import {
getIsElectronRuntime,
HEADER_INNER_HEIGHT,
@@ -606,6 +607,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
<HostConfirmationSheet />
<ProviderSettingsHost />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
+43 -42
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
@@ -7,10 +7,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { openPairScan } from "@/hosts/pair-scan-model";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
import { BackHeader } from "@/components/headers/back-header";
import { PairLinkModal } from "@/components/pair-link-modal";
import { Button } from "@/components/ui/button";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -73,6 +75,9 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 4,
borderBottomRightRadius: 12,
},
scanAgainButton: {
marginTop: theme.spacing[6],
},
helperText: {
marginTop: theme.spacing[6],
color: theme.colors.foregroundMuted,
@@ -108,16 +113,6 @@ const styles = StyleSheet.create((theme) => ({
},
}));
function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
const raw = typeof result.data === "string" ? result.data.trim() : "";
if (!raw) return null;
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://"))
return raw;
return null;
}
export default function PairScanScreen() {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -129,9 +124,6 @@ export default function PairScanScreen() {
const source = typeof params.source === "string" ? params.source : "settings";
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const [passwordOfferUrl, setPasswordOfferUrl] = useState<string | null>(null);
const navigateToPairedHost = useCallback(
(serverId: string) => {
@@ -144,6 +136,19 @@ export default function PairScanScreen() {
[router, source],
);
const [scan] = useState(() =>
openPairScan({
importConnectionLink: (link) =>
getHostRuntimeStore().importConnectionLink(
link,
source === "onboarding" ? "hostRoot" : "hostSettings",
),
onConnected: navigateToPairedHost,
}),
);
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
useEffect(() => () => scan.close(), [scan]);
const closeToSource = useCallback(() => {
try {
router.back();
@@ -159,30 +164,12 @@ export default function PairScanScreen() {
}, [permission, requestPermission]);
const handleScan = useCallback(
(result: BarcodeScanningResult) => {
if (isPairing) return;
const offerUrl = extractOfferUrlFromScan(result);
if (!offerUrl) return;
const store = getHostRuntimeStore();
if (passwordOfferUrl) return;
setIsPairing(true);
setScanError(null);
void store
.importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings")
.then((outcome) => {
if (outcome.status === "connected") navigateToPairedHost(outcome.serverId);
else setPasswordOfferUrl(offerUrl);
return outcome;
})
.catch((error) => setScanError(error instanceof Error ? error.message : String(error)))
.finally(() => setIsPairing(false));
},
[isPairing, navigateToPairedHost, passwordOfferUrl, source],
(result: BarcodeScanningResult) =>
scan.scan(typeof result.data === "string" ? result.data : ""),
[scan],
);
const handleRouterBack = useCallback(() => router.back(), [router]);
const closePasswordModal = useCallback(() => setPasswordOfferUrl(null), []);
const savePasswordPairing = useCallback(
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
[navigateToPairedHost],
@@ -240,24 +227,38 @@ export default function PairScanScreen() {
barcodeScannerSettings={BARCODE_SCANNER_SETTINGS}
onBarcodeScanned={handleScan}
/>
<View style={styles.overlay} pointerEvents="none">
<View style={styles.overlay} pointerEvents="box-none">
<View style={styles.scanFrame}>
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
{scanError ? <Text style={helperTextStyle}>{scanError}</Text> : null}
{scanState.status === "pairing" ? (
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
) : null}
{scanState.status === "stopped" && scanState.error ? (
<Text style={helperTextStyle}>{scanState.error}</Text>
) : null}
{scanState.status === "stopped" ? (
<Button
style={styles.scanAgainButton}
onPress={scan.scanAgain}
testID="pair-scan-again"
>
{t("pairing.connectionMethods.scanQr.title")}
</Button>
) : null}
</View>
</View>
)}
</View>
<PairLinkModal
visible={passwordOfferUrl !== null}
initialUrl={passwordOfferUrl ?? undefined}
initialPasswordRequired
onClose={closePasswordModal}
visible={scanState.status === "passwordRequired"}
passwordRequired={
scanState.status === "passwordRequired" ? scanState.passwordRequired : undefined
}
onClose={scan.closePassword}
onSaved={savePasswordPairing}
/>
</View>
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Modal, Platform, Pressable, Text, View } from "react-native";
import { Keyboard, Pressable, Text, View } from "react-native";
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -19,7 +19,6 @@ import {
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { ArrowLeft, Search, X } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
@@ -75,11 +74,15 @@ export interface SheetHeader {
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
const NATIVE_DIALOG_SNAP_POINTS = ["100%"];
const styles = StyleSheet.create((theme) => ({
nativeModalRoot: {
nativeDialogSurface: {
flex: 1,
},
nativeDialogBackground: {
backgroundColor: "transparent",
},
desktopOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.55)",
@@ -471,7 +474,7 @@ export interface AdaptiveModalSheetProps {
contentStyle?: StyleProp<ViewStyle>;
/** Size compact sheet content to the live snap height instead of its largest snap point. */
sizeContentToCurrentSnapPoint?: boolean;
/** Re-establishes caller-owned contexts inside the compact bottom-sheet portal. */
/** Re-establishes caller-owned contexts inside the native or compact sheet portal. */
contextBridge?: ContextBridge | null;
}
@@ -526,26 +529,26 @@ export function AdaptiveModalSheet({
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
);
useEffect(() => {
if (!isWeb && visible) {
// A newly opened sheet owns input. A keyboard belonging to the sheet below
// would cover controls in the new sheet, which has no focused input yet.
Keyboard.dismiss();
}
}, [visible]);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible,
isEnabled: isMobile,
isEnabled: isMobile || !isWeb,
onClose,
});
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
const [isWebClosing, setIsWebClosing] = useState(false);
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb);
const nativeModalDismissNotifiedRef = useRef(!visible);
const handleDismiss = useCallback(() => {
handleSheetDismiss();
onDismiss?.();
}, [handleSheetDismiss, onDismiss]);
const notifyNativeModalDismiss = useCallback(() => {
if (nativeModalDismissNotifiedRef.current) {
return;
}
nativeModalDismissNotifiedRef.current = true;
onDismiss?.();
}, [onDismiss]);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -592,12 +595,6 @@ export function AdaptiveModalSheet({
onKeyDown: handleWebOverlayKeyDown,
});
useEffect(() => {
if (visible) {
nativeModalDismissNotifiedRef.current = false;
}
}, [visible]);
useEffect(() => {
if (!isWeb || isMobile) return;
if (visible) {
@@ -615,12 +612,6 @@ export function AdaptiveModalSheet({
return () => window.clearTimeout(timeout);
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
useEffect(() => {
if (isWeb || isMobile || visible || Platform.OS !== "android") return;
const timeout = setTimeout(notifyNativeModalDismiss, 0);
return () => clearTimeout(timeout);
}, [visible, isMobile, notifyNativeModalDismiss]);
if (isMobile) {
const sheetContent = (
<>
@@ -725,18 +716,28 @@ export function AdaptiveModalSheet({
}
return (
<Modal
transparent
animationType="fade"
visible={visible}
onRequestClose={onClose}
onDismiss={notifyNativeModalDismiss}
hardwareAccelerated
// Both native presentations share Gorhom's app-wide stack. Independent RN Modals
// present from their React ancestor's controller, so a root-owned sibling dialog
// cannot present while that controller already has a dialog open on iOS.
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={contextBridge}
snapPoints={NATIVE_DIALOG_SNAP_POINTS}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleDismiss}
handleComponent={null}
backgroundStyle={styles.nativeDialogBackground}
enablePanDownToClose={false}
enableHandlePanningGesture={false}
enableContentPanningGesture={false}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
presentation={presentation}
>
{/* Android Modal opens a separate window outside the app's gesture root. */}
<GestureHandlerRootView style={styles.nativeModalRoot}>
{desktopContent}
</GestureHandlerRootView>
</Modal>
<View style={styles.nativeDialogSurface}>{desktopContent}</View>
</IsolatedBottomSheetModal>
);
}
+21 -25
View File
@@ -294,11 +294,23 @@ export interface AddHostModalProps {
}
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
return (
<AddHostModalContent
key={String(visible)}
visible={visible}
onClose={onClose}
onCancel={onCancel}
onSaved={onSaved}
/>
);
}
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertDirectConnection, probeAndUpsertConnectionFromOfferUrl } =
useHostMutations();
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
const [linkPairing] = useState(() => beginLinkPairing());
const isMobile = useIsCompactFormFactor();
const [isSaving, setIsSaving] = useState(false);
@@ -313,18 +325,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const advancedTarget = useRef(new PairingTargetTracker("", true));
const clearInput = useCallback(() => {
setHost("");
setPort("6767");
setUseTls(false);
setPassword("");
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
advancedTarget.current = new PairingTargetTracker("", true);
bumpInputResetKey();
}, []);
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
@@ -358,29 +358,25 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
);
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.direct.title") }), [t]);
// Each open mounts a fresh form (see AddHostModal), so closing needs no reset.
const handleClose = useCallback(() => {
if (isSaving) return;
clearInput();
setErrorMessage("");
onClose();
}, [isSaving, clearInput, onClose]);
}, [isSaving, onClose]);
const handleCancel = useCallback(() => {
if (isSaving) return;
clearInput();
setErrorMessage("");
(onCancel ?? onClose)();
}, [isSaving, clearInput, onCancel, onClose]);
}, [isSaving, onCancel, onClose]);
const handleSaveRelay = useCallback(
async (relayUri: string) => {
try {
setIsSaving(true);
setErrorMessage("");
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
relayUri,
password || undefined,
);
const result = await linkPairing.submit(relayUri, password || undefined);
if (result.status === "cancelled") return;
const { profile, serverId, hostname } = result;
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
@@ -396,9 +392,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
daemons,
directConnectionLabels.invalidConnection,
handleClose,
linkPairing,
onSaved,
password,
probeAndUpsertConnectionFromOfferUrl,
],
);
+15 -26
View File
@@ -5,7 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
@@ -62,8 +62,8 @@ const styles = StyleSheet.create((theme) => ({
export interface PairLinkModalProps {
visible: boolean;
initialUrl?: string;
initialPasswordRequired?: boolean;
/** Continues a confirmed pairing whose host asked for a password. */
passwordRequired?: PasswordRequiredPairing;
onClose: () => void;
onCancel?: () => void;
onSaved?: (result: {
@@ -76,18 +76,16 @@ export interface PairLinkModalProps {
export function PairLinkModal({
visible,
initialUrl,
initialPasswordRequired = false,
passwordRequired,
onClose,
onCancel,
onSaved,
}: PairLinkModalProps) {
return (
<PairLinkModalContent
key={`${visible}:${initialUrl ?? ""}:${initialPasswordRequired}`}
key={`${visible}:${passwordRequired?.link ?? ""}`}
visible={visible}
initialUrl={initialUrl}
initialPasswordRequired={initialPasswordRequired}
passwordRequired={passwordRequired}
onClose={onClose}
onCancel={onCancel}
onSaved={onSaved}
@@ -97,8 +95,7 @@ export function PairLinkModal({
function PairLinkModalContent({
visible,
initialUrl,
initialPasswordRequired = false,
passwordRequired,
onClose,
onCancel,
onSaved,
@@ -106,7 +103,9 @@ function PairLinkModalContent({
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertConnectionFromOfferUrl } = useHostMutations();
const { beginLinkPairing } = useHostMutations();
const [pairing] = useState(() => passwordRequired?.pairing ?? beginLinkPairing());
const initialUrl = passwordRequired?.link;
const isMobile = useIsCompactFormFactor();
const offerUrlRef = useRef(initialUrl ?? "");
@@ -115,7 +114,7 @@ function PairLinkModalContent({
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [password, setPassword] = useState("");
const [needsPassword, setNeedsPassword] = useState(initialPasswordRequired);
const [needsPassword, setNeedsPassword] = useState(passwordRequired !== undefined);
const [passwordResetKey, resetPasswordInput] = useReducer((key: number) => key + 1, 0);
const clearInput = useCallback(() => {
@@ -162,10 +161,9 @@ function PairLinkModalContent({
try {
setIsSaving(true);
setErrorMessage("");
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
raw,
password || undefined,
);
const result = await pairing.submit(raw, password || undefined);
if (result.status === "cancelled") return;
const { profile, serverId, hostname } = result;
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
@@ -184,16 +182,7 @@ function PairLinkModalContent({
setIsSaving(false);
}
},
[
daemons,
handleClose,
isMobile,
isSaving,
onSaved,
password,
t,
probeAndUpsertConnectionFromOfferUrl,
],
[daemons, handleClose, isMobile, isSaving, onSaved, password, t, pairing],
);
const handleChangeOfferUrl = useCallback((next: string) => {
@@ -0,0 +1,109 @@
import { useCallback, useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { useHostConfirmation } from "@/runtime/host-runtime";
/** Shows the host confirmation the host runtime is waiting on. Mount once at the app root. */
export function HostConfirmationSheet() {
const { t } = useTranslation();
const { pending, answer } = useHostConfirmation();
const cancel = useCallback(() => answer(false), [answer]);
const connect = useCallback(() => answer(true), [answer]);
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.hostConfirmation.title") }), [t]);
if (!pending) return null;
return (
<AdaptiveModalSheet header={header} visible onClose={cancel} testID="host-confirmation">
<Text style={styles.confirmText}>
{pending.kind === "changedConnection"
? t("pairing.hostConfirmation.descriptionChanged")
: t("pairing.hostConfirmation.description")}
</Text>
<View style={settingsStyles.card}>
<DetailRow
label={t("pairing.hostConfirmation.hostLabel")}
value={pending.serverId}
testID="host-confirmation-server-id"
/>
<DetailRow
label={t("pairing.hostConfirmation.fingerprintLabel")}
value={pending.keyFingerprint}
testID="host-confirmation-fingerprint"
showBorder
/>
<DetailRow
label={t("pairing.hostConfirmation.relayLabel")}
value={pending.relayEndpoint}
testID="host-confirmation-relay"
showBorder
/>
</View>
<View style={styles.confirmActions}>
<Button
variant="secondary"
size="sm"
style={FLEX_1_STYLE}
onPress={cancel}
testID="host-confirmation-cancel"
>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
size="sm"
style={FLEX_1_STYLE}
onPress={connect}
testID="host-confirmation-connect"
>
{t("pairing.hostConfirmation.connect")}
</Button>
</View>
</AdaptiveModalSheet>
);
}
function DetailRow({
label,
value,
showBorder = false,
testID,
}: {
label: string;
value: string;
showBorder?: boolean;
testID?: string;
}) {
return (
<View style={[settingsStyles.row, showBorder && settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{label}</Text>
<Text style={settingsStyles.rowHint} selectable testID={testID}>
{value}
</Text>
</View>
</View>
);
}
// Same shape as the remove-host and remove-connection confirm sheets in
// screens/settings/host-page.tsx.
const styles = StyleSheet.create((theme) => ({
confirmText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
confirmActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
}));
const FLEX_1_STYLE = { flex: 1 };
@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from "vitest";
import type { ConnectionLinkImport, LinkPairing } from "@/runtime/host-runtime";
import { openPairScan, type PairScanModel } from "./pair-scan-model";
const LINK = "https://app.paseo.sh/#offer=abc";
const PAIRING: LinkPairing = { submit: async () => ({ status: "cancelled" }) };
/** The host runtime's side of a scan: each import resolves with the next queued outcome. */
function createScanner(outcomes: Array<ConnectionLinkImport | Error>) {
const imports: string[] = [];
const connected: string[] = [];
const scan = openPairScan({
importConnectionLink: async (link) => {
imports.push(link);
const outcome = outcomes.shift();
if (!outcome) throw new Error("No outcome queued for this import");
if (outcome instanceof Error) throw outcome;
return outcome;
},
onConnected: (serverId) => connected.push(serverId),
});
return { scan, imports, connected };
}
async function settled(scan: PairScanModel): Promise<void> {
await vi.waitFor(() => expect(scan.getState().status).not.toBe("pairing"));
}
describe("openPairScan", () => {
it("stops scanning after Cancel until the user scans again", async () => {
const { scan, imports } = createScanner([{ status: "cancelled" }, { status: "cancelled" }]);
scan.scan(LINK);
await settled(scan);
// The code is still in front of the camera.
scan.scan(LINK);
scan.scan(LINK);
await Promise.resolve();
expect(imports).toEqual([LINK]);
expect(scan.getState()).toEqual({ status: "stopped", error: null });
scan.scanAgain();
scan.scan(LINK);
await settled(scan);
expect(imports).toEqual([LINK, LINK]);
});
it("starts one attempt per code while pairing, and ignores codes that are not pairing links", async () => {
const { scan, imports, connected } = createScanner([
{ status: "connected", serverId: "srv_scan" },
]);
scan.scan("https://example.com/not-a-pairing-link");
expect(scan.getState()).toEqual({ status: "scanning" });
scan.scan(LINK);
scan.scan(LINK);
await settled(scan);
expect(imports).toEqual([LINK]);
expect(connected).toEqual(["srv_scan"]);
scan.scan(LINK);
expect(imports).toEqual([LINK]);
});
it("ends the attempt when the password prompt closes", async () => {
const { scan, imports } = createScanner([
{ status: "password_required", link: LINK, pairing: PAIRING },
]);
scan.scan(LINK);
await settled(scan);
expect(scan.getState()).toEqual({
status: "passwordRequired",
passwordRequired: { link: LINK, pairing: PAIRING },
});
scan.scan(LINK);
scan.closePassword();
scan.scan(LINK);
expect(scan.getState()).toEqual({ status: "stopped", error: null });
expect(imports).toEqual([LINK]);
});
it("stops with the error when an attempt fails", async () => {
const { scan, imports } = createScanner([new Error("Server disconnected")]);
scan.scan(LINK);
await settled(scan);
scan.scan(LINK);
expect(scan.getState()).toEqual({ status: "stopped", error: "Server disconnected" });
expect(imports).toEqual([LINK]);
});
it("does not navigate for an attempt that finishes after the screen closed", async () => {
let finish: (outcome: ConnectionLinkImport) => void = () => {};
const connected: string[] = [];
const scan = openPairScan({
importConnectionLink: () =>
new Promise((resolve) => {
finish = resolve;
}),
onConnected: (serverId) => connected.push(serverId),
});
scan.scan(LINK);
scan.close();
finish({ status: "connected", serverId: "srv_scan" });
await Promise.resolve();
expect(connected).toEqual([]);
});
});
+103
View File
@@ -0,0 +1,103 @@
import type { ConnectionLinkImport, PasswordRequiredPairing } from "@/runtime/host-runtime";
/**
* One visit to the QR scan screen. The camera reports every frame that shows
* a code, so an attempt that ends (Cancel on the host confirmation, a closed
* password prompt, or an error) stops scanning until the user taps scan again.
* Without that, the code still in front of the camera starts a new attempt on
* the next frame.
*/
export type PairScanState =
| { status: "scanning" }
| { status: "pairing" }
| { status: "passwordRequired"; passwordRequired: PasswordRequiredPairing }
| { status: "stopped"; error: string | null }
| { status: "connected" };
export interface PairScanModel {
getState: () => PairScanState;
subscribe: (listener: () => void) => () => void;
/** Data from a QR code the camera saw. Only a scanning session starts an attempt. */
scan: (data: string) => void;
/** The user asked to scan again after an attempt ended. */
scanAgain: () => void;
/** The user closed the password prompt, which ends the attempt. */
closePassword: () => void;
close: () => void;
}
export interface PairScanDependencies {
importConnectionLink: (link: string) => Promise<ConnectionLinkImport>;
onConnected: (serverId: string) => void;
}
export function openPairScan(deps: PairScanDependencies): PairScanModel {
let state: PairScanState = { status: "scanning" };
let closed = false;
const listeners = new Set<() => void>();
function publish(next: PairScanState): void {
if (closed) return;
state = next;
for (const listener of listeners) {
listener();
}
}
async function pair(link: string): Promise<void> {
publish({ status: "pairing" });
try {
const outcome = await deps.importConnectionLink(link);
if (closed) return;
if (outcome.status === "connected") {
publish({ status: "connected" });
deps.onConnected(outcome.serverId);
return;
}
if (outcome.status === "password_required") {
const { link: passwordLink, pairing } = outcome;
publish({ status: "passwordRequired", passwordRequired: { link: passwordLink, pairing } });
return;
}
publish({ status: "stopped", error: null });
} catch (error) {
publish({ status: "stopped", error: error instanceof Error ? error.message : String(error) });
}
}
return {
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
scan(data) {
if (state.status !== "scanning") return;
const link = pairingLinkFromScan(data);
if (!link) return;
void pair(link);
},
scanAgain() {
if (state.status !== "stopped") return;
publish({ status: "scanning" });
},
closePassword() {
if (state.status !== "passwordRequired") return;
publish({ status: "stopped", error: null });
},
close() {
closed = true;
listeners.clear();
},
};
}
function pairingLinkFromScan(data: string): string | null {
const raw = data.trim();
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://")) {
return raw;
}
return null;
}
+11
View File
@@ -1621,6 +1621,17 @@ export const ar: TranslationResources = {
title: "كلمة المرور لـ {{host}}",
label: "كلمة مرور المضيف",
},
hostConfirmation: {
title: "الاتصال بهذا المضيف؟",
description:
"سيتمكن هذا المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.",
descriptionChanged:
"يغيّر هذا الرابط طريقة اتصالك بهذا المضيف. سيتمكن المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.",
hostLabel: "المضيف",
fingerprintLabel: "بصمة المفتاح",
relayLabel: "المُرحّل",
connect: "اتصال",
},
connectionMethods: {
title: "إضافة اتصال",
direct: {
+11
View File
@@ -1646,6 +1646,17 @@ export const en = {
title: "Password for {{host}}",
label: "Host password",
},
hostConfirmation: {
title: "Connect to this host?",
description:
"This host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.",
descriptionChanged:
"This link changes how you connect to this host. The host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.",
hostLabel: "Host",
fingerprintLabel: "Key fingerprint",
relayLabel: "Relay",
connect: "Connect",
},
connectionMethods: {
title: "Add connection",
direct: {
+11
View File
@@ -1667,6 +1667,17 @@ export const es: TranslationResources = {
title: "Contraseña de {{host}}",
label: "Contraseña del host",
},
hostConfirmation: {
title: "¿Conectar con este host?",
description:
"Este host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.",
descriptionChanged:
"Este enlace cambia cómo te conectas a este host. El host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.",
hostLabel: "Host",
fingerprintLabel: "Huella de la clave",
relayLabel: "Relay",
connect: "Conectar",
},
connectionMethods: {
title: "Agregar conexión",
direct: {
+11
View File
@@ -1671,6 +1671,17 @@ export const fr: TranslationResources = {
title: "Mot de passe pour {{host}}",
label: "Mot de passe de l’hôte",
},
hostConfirmation: {
title: "Se connecter à cet hôte ?",
description:
"Cet hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.",
descriptionChanged:
"Ce lien modifie la façon dont vous vous connectez à cet hôte. L'hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.",
hostLabel: "Hôte",
fingerprintLabel: "Empreinte de la clé",
relayLabel: "Relais",
connect: "Se connecter",
},
connectionMethods: {
title: "Ajouter une connexion",
direct: {
+11
View File
@@ -1638,6 +1638,17 @@ export const ja: TranslationResources = {
title: "{{host}} のパスワード",
label: "ホストのパスワード",
},
hostConfirmation: {
title: "このホストに接続しますか?",
description:
"このホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。",
descriptionChanged:
"このリンクはこのホストへの接続方法を変更します。ホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。",
hostLabel: "ホスト",
fingerprintLabel: "鍵のフィンガープリント",
relayLabel: "リレー",
connect: "接続",
},
connectionMethods: {
title: "接続を追加",
direct: {
+11
View File
@@ -1631,6 +1631,17 @@ export const ko: TranslationResources = {
title: "{{host}}의 비밀번호",
label: "호스트 비밀번호",
},
hostConfirmation: {
title: "이 호스트에 연결할까요?",
description:
"이 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.",
descriptionChanged:
"이 링크는 이 호스트에 연결하는 방식을 변경합니다. 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.",
hostLabel: "호스트",
fingerprintLabel: "키 지문",
relayLabel: "릴레이",
connect: "연결",
},
connectionMethods: {
title: "연결 추가",
direct: {
+11
View File
@@ -1652,6 +1652,17 @@ export const ptBR: TranslationResources = {
title: "Senha de {{host}}",
label: "Senha do host",
},
hostConfirmation: {
title: "Conectar a este host?",
description:
"Este host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.",
descriptionChanged:
"Este link muda como você se conecta a este host. O host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.",
hostLabel: "Host",
fingerprintLabel: "Impressão da chave",
relayLabel: "Relay",
connect: "Conectar",
},
connectionMethods: {
title: "Adicionar conexão",
direct: {
+11
View File
@@ -1650,6 +1650,17 @@ export const ru: TranslationResources = {
title: "Пароль для {{host}}",
label: "Пароль хоста",
},
hostConfirmation: {
title: "Подключиться к этому хосту?",
description:
"Этот хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.",
descriptionChanged:
"Эта ссылка меняет способ подключения к этому хосту. Хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.",
hostLabel: "Хост",
fingerprintLabel: "Отпечаток ключа",
relayLabel: "Реле",
connect: "Подключить",
},
connectionMethods: {
title: "Добавить подключение",
direct: {
+10
View File
@@ -1604,6 +1604,16 @@ export const zhCN: TranslationResources = {
title: "{{host}} 的密码",
label: "主机密码",
},
hostConfirmation: {
title: "连接到此主机?",
description: "此主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。",
descriptionChanged:
"此链接会改变你连接此主机的方式。该主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。",
hostLabel: "主机",
fingerprintLabel: "密钥指纹",
relayLabel: "中继",
connect: "连接",
},
connectionMethods: {
title: "添加连接",
direct: {
@@ -0,0 +1,97 @@
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import {
hostHasConnection,
relayConnectionFromOffer,
type HostProfile,
} from "@/types/host-connection";
/**
* The question shown before a pairing link adds a host that is not saved, or
* changes the key, relay, or TLS setting of a saved one.
*/
export interface HostConfirmationRequest {
/** Identifies this question, so an answer shown for it never answers a newer one. */
id: number;
serverId: string;
/** Grouped daemon public key the user can compare with the one their daemon shows. */
keyFingerprint: string;
relayEndpoint: string;
kind: "newHost" | "changedConnection";
}
interface PendingConfirmation {
request: HostConfirmationRequest;
resolve: (approved: boolean) => void;
}
/**
* Owns the one host confirmation the app is waiting on. The host runtime asks
* through `confirmLink`; the view reads `getPending` and replies with `answer`.
* A question waits until it is answered, whether or not a view is showing it.
*/
export class HostConfirmations {
private pending: PendingConfirmation | null = null;
private nextRequestId = 1;
private listeners = new Set<() => void>();
getPending(): HostConfirmationRequest | null {
return this.pending?.request ?? null;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
/** Answers the request with `requestId`; an answer for any other request is ignored. */
answer(requestId: number, approved: boolean): void {
const current = this.pending;
if (!current || current.request.id !== requestId) return;
this.pending = null;
this.emit();
current.resolve(approved);
}
/** Resolves true without asking when the link matches a saved host exactly. */
confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise<boolean> {
const connection = relayConnectionFromOffer(offer);
const savedHost = savedHosts.find((host) => host.serverId === offer.serverId);
if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true);
return this.ask({
id: this.nextRequestId++,
serverId: offer.serverId,
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
relayEndpoint: connection.relayEndpoint,
kind: savedHost ? "changedConnection" : "newHost",
});
}
private ask(request: HostConfirmationRequest): Promise<boolean> {
// One question at a time: a newer one cancels the unanswered one.
this.pending?.resolve(false);
return new Promise<boolean>((resolve) => {
this.pending = { request, resolve };
this.emit();
});
}
private emit(): void {
for (const listener of this.listeners) {
listener();
}
}
}
/**
* Shows the key material itself (not a re-hash) in groups of four. Keys longer
* than 16 characters show only their first and last 8, so two keys that share
* both ends show the same value.
*/
function formatDaemonKeyFingerprint(daemonPublicKeyB64: string): string {
const normalized = daemonPublicKeyB64.replace(/[^A-Za-z0-9]/g, "");
const group = (value: string): string => value.replace(/(.{4})/g, "$1 ").trim();
if (normalized.length <= 16) return group(normalized);
return `${group(normalized.slice(0, 8))} … ${group(normalized.slice(-8))}`;
}
+321 -110
View File
@@ -31,6 +31,7 @@ import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
import { subscriptionFixture } from "./subscription-fixture";
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
import type { HostConfirmationRequest } from "./host-confirmation";
it("requests the managed connection credential through desktop main without a web hint", async () => {
const requests: string[] = [];
@@ -507,6 +508,63 @@ function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient {
return client;
}
/**
* Answers every host confirmation the store asks, the way the sheet does, and
* records each request.
*/
function answerHostConfirmations(
store: HostRuntimeStore,
answer: boolean | ((request: HostConfirmationRequest, index: number) => boolean),
): HostConfirmationRequest[] {
const requests: HostConfirmationRequest[] = [];
store.subscribeHostConfirmation(() => {
const pending = store.getPendingHostConfirmation();
if (!pending) return;
requests.push(pending);
store.answerHostConfirmation(
pending.id,
typeof answer === "function" ? answer(pending, requests.length - 1) : answer,
);
});
return requests;
}
function requirePendingConfirmation(store: HostRuntimeStore): HostConfirmationRequest {
const pending = store.getPendingHostConfirmation();
if (!pending) throw new Error("Expected a pending host confirmation");
return pending;
}
function createPairingStore(
input: {
hostname?: string;
password?: string;
connectAttempts?: string[];
serverIdForHost?: (host: HostProfile) => string;
storage?: HostRuntimeStorage;
} = {},
): HostRuntimeStore {
return new HostRuntimeStore({
storage: input.storage ?? createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host, connection }) => {
input.connectAttempts?.push(connection.id);
if (input.password && host.password !== input.password) {
throw new DaemonAuthenticationError("password_required");
}
return {
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: input.serverIdForHost?.(host) ?? host.serverId,
hostname: input.hostname ?? "paired",
};
},
getClientId: async () => "cid_pairing",
readInitialConnectionHint: () => null,
},
});
}
function createMemoryHostRuntimeStorage(entries: Record<string, string> = {}): HostRuntimeStorage {
const values = new Map(Object.entries(entries));
return {
@@ -3547,19 +3605,10 @@ describe("HostRuntimeStore", () => {
});
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
const store = createPairingStore({ hostname: "mbp" });
answerHostConfirmations(store, true);
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.label).toBe("mbp");
@@ -3568,27 +3617,14 @@ describe("HostRuntimeStore", () => {
});
it("stores relay TLS from a pairing offer", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
const store = createPairingStore();
answerHostConfirmations(store, true);
await store.upsertConnectionFromOffer(
makeOffer({
relay: {
endpoint: "relay.example.com:443",
useTls: true,
},
}),
"tls relay",
);
await store
.beginLinkPairing()
.submit(
encodeOfferUrl(makeOffer({ relay: { endpoint: "relay.example.com:443", useTls: true } })),
);
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.connections).toEqual([
@@ -3605,17 +3641,8 @@ describe("HostRuntimeStore", () => {
});
it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
const store = createPairingStore();
answerHostConfirmations(store, true);
const oldPairingUrl = encodeOfferUrl({
v: 2,
serverId: "srv_offer",
@@ -3623,7 +3650,7 @@ describe("HostRuntimeStore", () => {
relay: { endpoint: "relay.paseo.sh:443" },
});
await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay");
await store.importConnectionLink(oldPairingUrl, "openProject");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.connections).toEqual([
@@ -3640,54 +3667,266 @@ describe("HostRuntimeStore", () => {
});
it("probes a pairing link immediately and saves only after admission", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => {
if (host.password !== "correct-password")
throw new DaemonAuthenticationError("password_required");
return {
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: "paired host",
};
},
getClientId: async () => "cid_pairing",
},
});
const store = createPairingStore({ password: "correct-password" });
answerHostConfirmations(store, true);
const offerUrl = encodeOfferUrl(makeOffer());
await expect(store.probeAndUpsertConnectionFromOfferUrl(offerUrl)).rejects.toThrow(
"Password required",
);
const pairing = store.beginLinkPairing();
await expect(pairing.submit(offerUrl)).rejects.toThrow("Password required");
expect(store.getHosts()).toHaveLength(0);
const result = await store.probeAndUpsertConnectionFromOfferUrl(offerUrl, "correct-password");
expect(result.serverId).toBe("srv_offer");
const result = await pairing.submit(offerUrl, "correct-password");
expect(result).toMatchObject({ status: "connected", serverId: "srv_offer" });
expect(store.getHosts()[0]?.password).toBe("correct-password");
store.syncHosts([]);
});
it("preserves the existing host label when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
it("asks before a pairing link adds a new host, and Cancel saves and connects nothing", async () => {
const connectAttempts: string[] = [];
const store = createPairingStore({ connectAttempts });
const requests = answerHostConfirmations(store, false);
const offerUrl = encodeOfferUrl(makeOffer());
const relayLink = "relay://relay.example:443/srv_relay?key=AAAA&ssl=true";
// `#offer=` in the app URL, then a QR scan, then the pair-link and add-host modals.
await expect(store.importConnectionLink(offerUrl, "openProject")).resolves.toEqual({
status: "cancelled",
});
await expect(store.importConnectionLink(offerUrl, "hostRoot")).resolves.toEqual({
status: "cancelled",
});
await expect(store.beginLinkPairing().submit(offerUrl)).resolves.toEqual({
status: "cancelled",
});
await expect(store.beginLinkPairing().submit(relayLink)).resolves.toEqual({
status: "cancelled",
});
expect(requests).toEqual([
...Array.from({ length: 3 }, () => ({
id: expect.any(Number),
serverId: "srv_offer",
keyFingerprint: "pkte stof fer",
relayEndpoint: "relay.paseo.sh:443",
kind: "newHost",
})),
{
id: expect.any(Number),
serverId: "srv_relay",
keyFingerprint: "AAAA",
relayEndpoint: "relay.example:443",
kind: "newHost",
},
storage: createMemoryHostRuntimeStorage(),
});
]);
expect(store.getHosts()).toEqual([]);
expect(connectAttempts).toEqual([]);
store.syncHosts([]);
});
await store.upsertRelayConnection({
it("connects a saved host from a matching pairing link without asking", async () => {
const store = createPairingStore();
const requests = answerHostConfirmations(store, true);
const offerUrl = encodeOfferUrl(makeOffer());
await store.beginLinkPairing().submit(offerUrl);
expect(requests).toHaveLength(1);
await expect(store.importConnectionLink(offerUrl, "openProject")).resolves.toEqual({
status: "connected",
serverId: "srv_offer",
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "pk_test_offer",
label: "Custom name",
});
await expect(store.importConnectionLink(offerUrl, "hostRoot")).resolves.toEqual({
status: "connected",
serverId: "srv_offer",
});
await expect(store.beginLinkPairing().submit(offerUrl)).resolves.toMatchObject({
status: "connected",
});
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
expect(requests).toHaveLength(1);
expect(store.getHosts()).toHaveLength(1);
store.syncHosts([]);
});
it("asks when a pairing link changes a saved host's key, relay, or TLS setting", async () => {
const store = createPairingStore();
const requests = answerHostConfirmations(store, (_request, index) => index === 0);
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
const savedHosts = store.getHosts();
const changedLinks = [
makeOffer({ daemonPublicKeyB64: "pk_other_key" }),
makeOffer({ relay: { endpoint: "relay.example:443", useTls: false } }),
makeOffer({ relay: { endpoint: "relay.paseo.sh:443", useTls: true } }),
].map(encodeOfferUrl);
for (const link of changedLinks) {
await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({
status: "cancelled",
});
await expect(store.beginLinkPairing().submit(link)).resolves.toEqual({
status: "cancelled",
});
}
expect(requests.map((request) => request.kind)).toEqual([
"newHost",
...Array.from({ length: 6 }, () => "changedConnection"),
]);
expect(store.getHosts()).toEqual(savedHosts);
store.syncHosts([]);
});
it("asks once per pairing flow and link when the host asks for a password", async () => {
const store = createPairingStore({ password: "correct-password" });
const requests = answerHostConfirmations(store, (_request, index) => index < 2);
const offerUrl = encodeOfferUrl(makeOffer());
const pairing = store.beginLinkPairing();
await expect(pairing.submit(offerUrl)).rejects.toThrow("Password required");
await expect(pairing.submit(offerUrl, "wrong-password")).rejects.toThrow();
expect(requests).toHaveLength(1);
// A new pairing flow for the same link asks again.
await expect(store.beginLinkPairing().submit(offerUrl, "wrong-password")).rejects.toThrow();
expect(requests).toHaveLength(2);
await expect(pairing.submit(offerUrl, "correct-password")).resolves.toMatchObject({
status: "connected",
});
expect(requests).toHaveLength(2);
expect(store.getHosts()[0]).toMatchObject({
serverId: "srv_offer",
password: "correct-password",
});
// A different link in the same flow asks again.
const otherKeyUrl = encodeOfferUrl(makeOffer({ daemonPublicKeyB64: "pk_other_key" }));
await expect(pairing.submit(otherKeyUrl, "correct-password")).resolves.toEqual({
status: "cancelled",
});
expect(requests).toHaveLength(3);
store.syncHosts([]);
});
it("continues a scanned link's password retry without asking again", async () => {
const store = createPairingStore({ password: "correct-password" });
const requests = answerHostConfirmations(store, true);
const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true";
const outcome = await store.importConnectionLink(link, "hostRoot");
expect(outcome).toMatchObject({ status: "password_required", link });
expect(store.getHosts()).toHaveLength(0);
if (outcome.status !== "password_required") throw new Error("expected password_required");
await outcome.pairing.submit(outcome.link, "correct-password");
expect(requests).toHaveLength(1);
expect(store.getHosts()[0]).toMatchObject({
serverId: "srv_pair",
password: "correct-password",
});
store.syncHosts([]);
});
it("keeps a host confirmation pending until it is answered, and a newer one cancels it", async () => {
const store = createPairingStore();
const first = store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject");
await vi.waitFor(() =>
expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_offer" }),
);
const second = store.importConnectionLink(
encodeOfferUrl(makeOffer({ serverId: "srv_second" })),
"openProject",
);
await expect(first).resolves.toEqual({ status: "cancelled" });
await vi.waitFor(() =>
expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_second" }),
);
store.answerHostConfirmation(requirePendingConfirmation(store).id, true);
await expect(second).resolves.toEqual({ status: "connected", serverId: "srv_second" });
expect(store.getPendingHostConfirmation()).toBeNull();
expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_second"]);
store.syncHosts([]);
});
it("ignores an answer for a host confirmation that a newer one replaced", async () => {
const store = createPairingStore();
const first = store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject");
await vi.waitFor(() =>
expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_offer" }),
);
const firstRequest = requirePendingConfirmation(store);
const second = store.importConnectionLink(
encodeOfferUrl(makeOffer({ serverId: "srv_second" })),
"openProject",
);
await expect(first).resolves.toEqual({ status: "cancelled" });
// Connect tapped on the first sheet as the second link arrives.
store.answerHostConfirmation(firstRequest.id, true);
const secondRequest = requirePendingConfirmation(store);
expect(secondRequest.serverId).toBe("srv_second");
store.answerHostConfirmation(secondRequest.id, false);
await expect(second).resolves.toEqual({ status: "cancelled" });
expect(store.getHosts()).toEqual([]);
});
it("waits for saved hosts to load before deciding whether a pairing link asks", async () => {
const savedHost = makeHost({
serverId: "srv_offer",
connections: [
{
id: "relay:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
useTls: false,
daemonPublicKeyB64: "pk_test_offer",
},
],
});
const store = createPairingStore({
storage: createMemoryHostRuntimeStorage({
"@paseo:daemon-registry": JSON.stringify([savedHost]),
"@paseo:e2e": "1",
}),
});
const requests = answerHostConfirmations(store, true);
await expect(
store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject"),
).resolves.toEqual({ status: "connected", serverId: "srv_offer" });
expect(requests).toEqual([]);
expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_offer"]);
store.syncHosts([]);
});
it("adds hosts by address and the default localhost without asking", async () => {
const store = createPairingStore({
serverIdForHost: (host) =>
host.connections[0]?.type === "directTcp" ? `srv_${host.connections[0].endpoint}` : "",
});
const requests = answerHostConfirmations(store, false);
await store.boot();
await store.probeAndUpsertDirectConnection({ endpoint: "10.0.0.5:6767" });
expect(requests).toEqual([]);
expect(store.getHosts().map((host) => host.serverId)).toEqual([
"srv_localhost:6767",
"srv_10.0.0.5:6767",
]);
store.syncHosts([]);
});
it("preserves the existing host label when re-pairing an existing relay host", async () => {
const store = createPairingStore({ hostname: "mbp" });
answerHostConfirmations(store, true);
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
await store.renameHost("srv_offer", "Custom name");
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.label).toBe("Custom name");
@@ -3963,35 +4202,6 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
expect(probeAttempts).toBe(attemptsBeforeRetry);
store.syncHosts([]);
});
it("imports a pairing link after a password retry in the add flow", async () => {
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => {
if (host.password !== "correct-password")
throw new DaemonAuthenticationError("password_required");
return {
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: "paired",
};
},
getClientId: async () => "cid_link_import",
},
});
const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true";
await expect(store.importConnectionLink(link, "hostRoot")).resolves.toEqual({
status: "password_required",
});
expect(store.getHosts()).toHaveLength(0);
await store.probeAndUpsertConnectionFromOfferUrl(link, "correct-password");
expect(store.getHosts()[0]).toMatchObject({
serverId: "srv_pair",
password: "correct-password",
});
store.syncHosts([]);
});
it("imports a deep link without waiting for admission, then leaves a rejected saved host red", async () => {
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
@@ -4003,6 +4213,7 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
getClientId: async () => "cid_deep_link",
},
});
answerHostConfirmations(store, true);
const link = "relay://relay.example:443/srv_deep?key=AAAA&ssl=true";
await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({
status: "connected",
+138 -76
View File
@@ -14,6 +14,7 @@ import {
normalizeStoredHostProfile,
upsertHostConnectionInProfiles,
registryHasConnection,
relayConnectionFromOffer,
StoredHostRegistrySchema,
type HostConnection,
type HostProfile,
@@ -29,6 +30,7 @@ import {
} from "@/utils/daemon-endpoints";
import { resolveAppVersion } from "@/utils/app-version";
import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import { HostConfirmations, type HostConfirmationRequest } from "./host-confirmation";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { isWeb } from "@/constants/platform";
import { connectToDaemon, getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
@@ -89,6 +91,27 @@ export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "of
export type PairingNavigationTarget = "openProject" | "hostRoot" | "hostSettings";
export type HostRegistryStatus = "loading" | "ready";
/** One pairing flow started with `HostRuntimeStore.beginLinkPairing`. */
export interface LinkPairing {
/** Probes the linked host and saves it. Connection and password errors throw. */
submit(link: string, password?: string): Promise<LinkPairingResult>;
}
export type LinkPairingResult =
| { status: "connected"; profile: HostProfile; serverId: string; hostname: string | null }
| { status: "cancelled" };
/** A pairing link the user confirmed whose host then asked for a password. */
export interface PasswordRequiredPairing {
link: string;
pairing: LinkPairing;
}
export type ConnectionLinkImport =
| { status: "connected"; serverId: string }
| ({ status: "password_required" } & PasswordRequiredPairing)
| { status: "cancelled" };
export type ActiveConnection =
| { type: "directTcp"; endpoint: string; display: string }
| { type: "directSocket"; endpoint: string; display: "socket" }
@@ -1415,6 +1438,13 @@ function readConfiguredLocalDaemonOverride(): string | null {
return value && value.length > 0 ? value : null;
}
function isSameLinkedConnection(left: ConnectionOffer, right: ConnectionOffer): boolean {
return (
left.serverId === right.serverId &&
equal(relayConnectionFromOffer(left), relayConnectionFromOffer(right))
);
}
function parseOfferConnectionUrl(input: string): { offer: ConnectionOffer; password?: string } {
if (input.trim().startsWith("relay://") || input.includes("#connect=")) {
return parseRelayConnectionUri(input);
@@ -1476,6 +1506,8 @@ export class HostRuntimeStore {
private timelineReplicaByServer = new Map<string, TimelineReplica>();
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
private bootPromise: Promise<void> | null = null;
private registryLoad: Promise<void> | null = null;
private readonly hostConfirmations = new HostConfirmations();
private storage: HostRuntimeStorage;
private replicaCache: ReplicaCache;
private readonly revokePushNotifications: typeof revokePushNotifications;
@@ -1526,8 +1558,7 @@ export class HostRuntimeStore {
private async runBoot(): Promise<void> {
const override = readConfiguredLocalDaemonOverride();
await this.loadFromStorage();
this.markHostRegistryLoaded();
await this.loadRegistry();
let isE2E: string | null = null;
try {
@@ -1560,6 +1591,13 @@ export class HostRuntimeStore {
}
}
private loadRegistry(): Promise<void> {
if (!this.registryLoad) {
this.registryLoad = this.loadFromStorage().then(() => this.markHostRegistryLoaded());
}
return this.registryLoad;
}
private async loadFromStorage(): Promise<void> {
let shouldPersistHosts = false;
let profiles: HostProfile[] = [];
@@ -1886,7 +1924,7 @@ export class HostRuntimeStore {
});
}
async upsertRelayConnection(input: {
private async upsertRelayConnection(input: {
serverId: string;
relayEndpoint: string;
useTls?: boolean;
@@ -1919,51 +1957,91 @@ export class HostRuntimeStore {
return profile;
}
async upsertConnectionFromOffer(
// A pairing link (a URL, a QR code, or a pasted link) asks the user to
// confirm when it adds a new host or changes a saved host's key, relay, or
// TLS setting. The answer needs the saved hosts, so it waits for them to load.
private async confirmLink(offer: ConnectionOffer): Promise<boolean> {
await this.loadRegistry();
return this.hostConfirmations.confirmLink(offer, this.hosts);
}
getPendingHostConfirmation(): HostConfirmationRequest | null {
return this.hostConfirmations.getPending();
}
subscribeHostConfirmation(listener: () => void): () => void {
return this.hostConfirmations.subscribe(listener);
}
answerHostConfirmation(requestId: number, approved: boolean): void {
this.hostConfirmations.answer(requestId, approved);
}
/**
* Starts one pairing flow, such as one open pairing modal. Once the user
* confirms a link, submitting the same link again in this flow (the
* password retry) does not ask again. A new flow asks again.
*/
beginLinkPairing(): LinkPairing {
let approvedOffer: ConnectionOffer | null = null;
return {
submit: async (link, password) => {
const { offer, password: linkPassword } = parseOfferConnectionUrl(link);
if (!approvedOffer || !isSameLinkedConnection(approvedOffer, offer)) {
if (!(await this.confirmLink(offer))) return { status: "cancelled" };
approvedOffer = offer;
}
const result = await this.probeAndUpsertOffer(offer, password ?? linkPassword);
return { status: "connected", ...result };
},
};
}
async importConnectionLink(
url: string,
target: PairingNavigationTarget,
): Promise<ConnectionLinkImport> {
if (target === "openProject") {
const { offer, password } = parseOfferConnectionUrl(url);
if (!(await this.confirmLink(offer))) return { status: "cancelled" };
const profile = await this.upsertConnectionFromOffer(offer, undefined, password);
return { status: "connected", serverId: profile.serverId };
}
const pairing = this.beginLinkPairing();
try {
const result = await pairing.submit(url);
if (result.status === "cancelled") return result;
return { status: "connected", serverId: result.serverId };
} catch (error) {
if (!getConnectionAuthFailureReason(error)) throw error;
return { status: "password_required", link: url, pairing };
}
}
private async upsertConnectionFromOffer(
offer: ConnectionOffer,
label?: string,
password?: string,
): Promise<HostProfile> {
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
const connection = relayConnectionFromOffer(offer);
return this.upsertRelayConnection({
serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint,
useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
relayEndpoint: connection.relayEndpoint,
useTls: connection.useTls,
daemonPublicKeyB64: connection.daemonPublicKeyB64,
label,
password,
});
}
async upsertConnectionFromOfferUrl(
offerUrlOrFragment: string,
label?: string,
password?: string,
): Promise<HostProfile> {
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
return this.upsertConnectionFromOffer(parsed.offer, label, password ?? parsed.password);
}
async probeAndUpsertConnectionFromOfferUrl(
offerUrlOrFragment: string,
password?: string,
private async probeAndUpsertOffer(
offer: ConnectionOffer,
password: string | undefined,
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
const offer = parsed.offer;
const credential = password ?? parsed.password;
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
const relayEndpoint = normalizeHostPort(offer.relay.endpoint);
const connection: HostConnection = {
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
type: "relay",
relayEndpoint,
useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
};
const connection = relayConnectionFromOffer(offer);
const probeHost: HostProfile = {
serverId: offer.serverId,
...(credential ? { password: credential } : {}),
...(password ? { password } : {}),
label: offer.serverId,
appearance: defaultHostAppearance(),
lifecycle: {},
@@ -1974,27 +2052,10 @@ export class HostRuntimeStore {
};
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
await client.close().catch(() => undefined);
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, credential);
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password);
return { profile, serverId: offer.serverId, hostname };
}
async importConnectionLink(
url: string,
target: PairingNavigationTarget,
): Promise<{ status: "connected"; serverId: string } | { status: "password_required" }> {
if (target === "openProject") {
const profile = await this.upsertConnectionFromOfferUrl(url);
return { status: "connected", serverId: profile.serverId };
}
try {
const { serverId } = await this.probeAndUpsertConnectionFromOfferUrl(url);
return { status: "connected", serverId };
} catch (error) {
if (!getConnectionAuthFailureReason(error)) throw error;
return { status: "password_required" };
}
}
async upsertConnectionFromListen(input: {
listenAddress: string;
serverId: string;
@@ -2742,6 +2803,29 @@ export function useHostRegistryLoaded(): boolean {
);
}
export function useHostConfirmation(): {
pending: HostConfirmationRequest | null;
answer: (approved: boolean) => void;
} {
const store = getHostRuntimeStore();
const pending = useSyncExternalStore(
(onStoreChange) => store.subscribeHostConfirmation(onStoreChange),
() => store.getPendingHostConfirmation(),
() => store.getPendingHostConfirmation(),
);
// The answer is bound to the request this render shows, so a tap never
// answers a request that replaced it.
return useMemo(
() => ({
pending,
answer: (approved: boolean) => {
if (pending) store.answerHostConfirmation(pending.id, approved);
},
}),
[pending, store],
);
}
export interface HostMutations {
upsertDirectConnection: (input: {
serverId: string;
@@ -2762,24 +2846,7 @@ export interface HostMutations {
daemonPort?: number;
label?: string;
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
upsertRelayConnection: (input: {
serverId: string;
relayEndpoint: string;
useTls?: boolean;
daemonPublicKeyB64: string;
label?: string;
password?: string;
}) => Promise<HostProfile>;
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
upsertConnectionFromOfferUrl: (
offerUrlOrFragment: string,
label?: string,
password?: string,
) => Promise<HostProfile>;
probeAndUpsertConnectionFromOfferUrl: (
offerUrlOrFragment: string,
password?: string,
) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
beginLinkPairing: () => LinkPairing;
renameHost: (serverId: string, label: string) => Promise<void>;
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
@@ -2794,12 +2861,7 @@ export function useHostMutations(): HostMutations {
upsertDirectConnection: (input) => store.upsertDirectConnection(input),
probeAndUpsertDirectConnection: (input) => store.probeAndUpsertDirectConnection(input),
probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input),
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
upsertConnectionFromOfferUrl: (url, label, password) =>
store.upsertConnectionFromOfferUrl(url, label, password),
probeAndUpsertConnectionFromOfferUrl: (url, password) =>
store.probeAndUpsertConnectionFromOfferUrl(url, password),
beginLinkPairing: () => store.beginLinkPairing(),
renameHost: (serverId, label) => store.renameHost(serverId, label),
setHostColor: (serverId, color) => store.setHostColor(serverId, color),
setHostBadgeDisplay: (serverId, badgeDisplay) =>
+15
View File
@@ -1,6 +1,8 @@
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import {
normalizeHostPort,
normalizeLoopbackToLocalhost,
shouldUseTlsForDefaultHostedRelay,
} from "@getpaseo/protocol/daemon-endpoints";
import {
DirectTcpHostConnectionSchema,
@@ -354,6 +356,19 @@ export function connectionFromListen(listen: string): HostConnection | null {
}
}
export function relayConnectionFromOffer(offer: ConnectionOffer): RelayHostConnection {
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
const relayEndpoint = normalizeHostPort(offer.relay.endpoint);
return {
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
type: "relay",
relayEndpoint,
useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64.trim(),
};
}
export function createRemoteSshHostConnection(input: {
host: string;
sshPort?: number;