* Extract client SDK package * Polish SDK client identity defaults * Build client before dependent CI jobs * Restore daemon client server export * Extract protocol and client SDK packages * Fix provider override schema validation * Fix app test daemon client imports * Simplify workspace build targets * Fix CLI test server build bootstrap * Run SDK package tests in CI * Fix rebase package split drift * Restore lockfile registry metadata * Update SDK config test for prompt default * Move terminal stream router test to client package * Fix rebase drift for protocol imports * Fix SDK agent capability fixture * Restore legacy server client exports * Fix server export compatibility test * Advertise custom mode icon client capability * Remove server daemon-client exports * Format rebased mode control import * Fix rebase drift for protocol imports Files added by upstream PRs (#893, #1147, #1154) referenced the pre-split shared/ paths that this branch moves into @getpaseo/protocol. Redirect those imports to the protocol package so typecheck stays green after the rebase.
227 lines
6.8 KiB
TypeScript
227 lines
6.8 KiB
TypeScript
import { useCallback, useMemo, useRef, useState } from "react";
|
|
import { Alert, Text, TextInput, View } from "react-native";
|
|
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 { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
|
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
|
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
|
const PAIR_LINK_HEADER: SheetHeader = { title: "Paste pairing link" };
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
helper: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
field: {
|
|
gap: theme.spacing[2],
|
|
},
|
|
label: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.medium,
|
|
},
|
|
input: {
|
|
backgroundColor: theme.colors.surface2,
|
|
borderRadius: theme.borderRadius.lg,
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingVertical: theme.spacing[3],
|
|
color: theme.colors.foreground,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
},
|
|
error: {
|
|
color: theme.colors.destructive,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
actions: {
|
|
flexDirection: "row",
|
|
gap: theme.spacing[3],
|
|
marginTop: theme.spacing[2],
|
|
},
|
|
}));
|
|
|
|
export interface PairLinkModalProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
onCancel?: () => void;
|
|
onSaved?: (result: {
|
|
profile: HostProfile;
|
|
serverId: string;
|
|
hostname: string | null;
|
|
isNewHost: boolean;
|
|
}) => void;
|
|
}
|
|
|
|
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
|
|
const { theme } = useUnistyles();
|
|
const daemons = useHosts();
|
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
|
const isMobile = useIsCompactFormFactor();
|
|
|
|
const offerUrlRef = useRef("");
|
|
const inputRef = useRef<TextInput>(null);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState("");
|
|
|
|
const clearInput = useCallback(() => {
|
|
offerUrlRef.current = "";
|
|
inputRef.current?.clear();
|
|
}, []);
|
|
|
|
const pairIcon = useMemo(
|
|
() => <Link size={16} color={theme.colors.palette.white} />,
|
|
[theme.colors.palette.white],
|
|
);
|
|
|
|
const handleClose = useCallback(() => {
|
|
if (isSaving) return;
|
|
clearInput();
|
|
setErrorMessage("");
|
|
onClose();
|
|
}, [isSaving, clearInput, onClose]);
|
|
|
|
const handleCancel = useCallback(() => {
|
|
if (isSaving) return;
|
|
clearInput();
|
|
setErrorMessage("");
|
|
(onCancel ?? onClose)();
|
|
}, [isSaving, clearInput, onCancel, onClose]);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (isSaving) return;
|
|
const raw = offerUrlRef.current.trim();
|
|
if (!raw) {
|
|
setErrorMessage("Paste a pairing link (…/#offer=...)");
|
|
return;
|
|
}
|
|
if (!raw.includes("#offer=")) {
|
|
setErrorMessage("Link must include #offer=...");
|
|
return;
|
|
}
|
|
|
|
const parsedOffer = (() => {
|
|
try {
|
|
const idx = raw.indexOf("#offer=");
|
|
const encoded = raw.slice(idx + "#offer=".length).trim();
|
|
if (!encoded) {
|
|
throw new Error("Offer payload is empty");
|
|
}
|
|
const payload = decodeOfferFragmentPayload(encoded);
|
|
return ConnectionOfferSchema.parse(payload);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Invalid pairing link";
|
|
setErrorMessage(message);
|
|
if (!isMobile) {
|
|
Alert.alert("Pairing failed", message);
|
|
}
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
if (!parsedOffer) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
setErrorMessage("");
|
|
|
|
const { client, hostname } = await connectToDaemon(
|
|
{
|
|
id: "probe",
|
|
type: "relay",
|
|
relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint),
|
|
useTls: parsedOffer.relay.useTls,
|
|
daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64,
|
|
},
|
|
{ serverId: parsedOffer.serverId },
|
|
);
|
|
await client.close().catch(() => undefined);
|
|
|
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
|
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
|
|
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
|
handleClose();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unable to pair host";
|
|
setErrorMessage(message);
|
|
if (!isMobile) {
|
|
Alert.alert("Pairing failed", message);
|
|
}
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]);
|
|
|
|
const handleChangeOfferUrl = useCallback((next: string) => {
|
|
offerUrlRef.current = next;
|
|
}, []);
|
|
|
|
const handleSavePress = useCallback(() => {
|
|
void handleSave();
|
|
}, [handleSave]);
|
|
|
|
return (
|
|
<AdaptiveModalSheet
|
|
header={PAIR_LINK_HEADER}
|
|
visible={visible}
|
|
onClose={handleClose}
|
|
testID="pair-link-modal"
|
|
>
|
|
<Text style={styles.helper}>Paste the pairing link from your server.</Text>
|
|
|
|
<View style={styles.field}>
|
|
<Text style={styles.label}>Pairing link</Text>
|
|
<AdaptiveTextInput
|
|
ref={inputRef}
|
|
testID="pair-link-input"
|
|
nativeID="pair-link-input"
|
|
accessibilityLabel="pair-link-input"
|
|
onChangeText={handleChangeOfferUrl}
|
|
placeholder="https://app.paseo.sh/#offer=..."
|
|
placeholderTextColor={theme.colors.foregroundMuted}
|
|
style={styles.input}
|
|
autoFocus
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
keyboardType="url"
|
|
/>
|
|
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
|
</View>
|
|
|
|
<View style={styles.actions}>
|
|
<Button
|
|
style={FLEX_ONE_STYLE}
|
|
variant="secondary"
|
|
onPress={handleCancel}
|
|
disabled={isSaving}
|
|
testID="pair-link-cancel"
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Cancel"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
style={FLEX_ONE_STYLE}
|
|
variant="default"
|
|
onPress={handleSavePress}
|
|
disabled={isSaving}
|
|
testID="pair-link-submit"
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Pair"
|
|
leftIcon={pairIcon}
|
|
>
|
|
{isSaving ? "Pairing..." : "Pair"}
|
|
</Button>
|
|
</View>
|
|
</AdaptiveModalSheet>
|
|
);
|
|
}
|