* 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.
270 lines
8.6 KiB
TypeScript
270 lines
8.6 KiB
TypeScript
import { useCallback, useEffect, useState, type ComponentType } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { View, Text, Pressable } from "react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { useRouter } from "expo-router";
|
|
import { FolderOpen, Inbox, Plug, Smartphone } from "lucide-react-native";
|
|
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
|
import { CommunityLinks } from "@/components/community-links";
|
|
import { MenuHeader } from "@/components/headers/menu-header";
|
|
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
|
import { useHostChooser } from "@/hosts/host-chooser";
|
|
import { usePanelStore } from "@/stores/panel-store";
|
|
import {
|
|
useIsCompactFormFactor,
|
|
HEADER_INNER_HEIGHT,
|
|
HEADER_INNER_HEIGHT_MOBILE,
|
|
HEADER_TOP_PADDING_MOBILE,
|
|
} from "@/constants/layout";
|
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
|
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
|
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
|
import { buildHostAgentDetailRoute, buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
|
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
|
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
|
import { useOpenProject } from "@/hooks/use-open-project";
|
|
import type { Href } from "expo-router";
|
|
|
|
export function OpenProjectScreen() {
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
|
|
const openProjectPicker = useOpenAddProject();
|
|
const chooseHost = useHostChooser();
|
|
const localServerId = useLocalDaemonServerId();
|
|
const [importServerId, setImportServerId] = useState<string | null>(null);
|
|
const importClient = useHostRuntimeClient(importServerId ?? "");
|
|
const openImportedProject = useOpenProject(importServerId);
|
|
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
|
|
const [isImportSheetOpen, setIsImportSheetOpen] = useState(false);
|
|
|
|
const isCompactLayout = useIsCompactFormFactor();
|
|
|
|
useEffect(() => {
|
|
if (!isCompactLayout) {
|
|
openDesktopAgentList();
|
|
}
|
|
}, [isCompactLayout, openDesktopAgentList]);
|
|
|
|
const handleOpenPicker = useCallback(() => {
|
|
void openProjectPicker();
|
|
}, [openProjectPicker]);
|
|
|
|
const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []);
|
|
const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []);
|
|
|
|
const handleOpenImportSession = useCallback(() => {
|
|
chooseHost({
|
|
title: "Import from host",
|
|
onChooseHost: (serverId) => {
|
|
setImportServerId(serverId);
|
|
setIsImportSheetOpen(true);
|
|
},
|
|
});
|
|
}, [chooseHost]);
|
|
const handleCloseImportSession = useCallback(() => setIsImportSheetOpen(false), []);
|
|
|
|
const handleImported = useCallback(
|
|
(agent: { id: string; cwd: string }) => {
|
|
if (!importServerId) return;
|
|
void (async () => {
|
|
const result = await openImportedProject(agent.cwd);
|
|
if (result.ok) {
|
|
router.push(buildHostAgentDetailRoute(importServerId, agent.id) as Href);
|
|
}
|
|
})();
|
|
},
|
|
[importServerId, openImportedProject, router],
|
|
);
|
|
|
|
const handleOpenProviders = useCallback(() => {
|
|
chooseHost({
|
|
title: "Choose host",
|
|
onChooseHost: (serverId) => {
|
|
router.push(buildSettingsHostSectionRoute(serverId, "providers"));
|
|
},
|
|
});
|
|
}, [chooseHost, router]);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<MenuHeader borderless />
|
|
<View style={styles.content}>
|
|
<TitlebarDragRegion />
|
|
<View style={styles.logo}>
|
|
<PaseoLogo size={52} />
|
|
</View>
|
|
<View style={styles.tiles}>
|
|
<HomeTile
|
|
icon={FolderOpen}
|
|
title={t("openProject.tiles.addProject.title")}
|
|
description={t("openProject.tiles.addProject.description")}
|
|
onPress={handleOpenPicker}
|
|
testID="open-project-submit"
|
|
accent
|
|
/>
|
|
<HomeTile
|
|
icon={Inbox}
|
|
title={t("openProject.tiles.importSession.title")}
|
|
description={t("openProject.tiles.importSession.description")}
|
|
onPress={handleOpenImportSession}
|
|
testID="open-project-import-session"
|
|
/>
|
|
<HomeTile
|
|
icon={Plug}
|
|
title={t("openProject.tiles.setupProviders.title")}
|
|
description={t("openProject.tiles.setupProviders.description")}
|
|
onPress={handleOpenProviders}
|
|
testID="open-project-setup-providers"
|
|
/>
|
|
{localServerId ? (
|
|
<HomeTile
|
|
icon={Smartphone}
|
|
title={t("openProject.tiles.pairDevice.title")}
|
|
description={t("openProject.tiles.pairDevice.description")}
|
|
onPress={handleOpenPairDevice}
|
|
testID="open-project-pair-device"
|
|
/>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
<View style={styles.communityRow}>
|
|
<CommunityLinks />
|
|
</View>
|
|
<PairDeviceModal
|
|
serverId={localServerId ?? ""}
|
|
visible={isPairDeviceOpen}
|
|
onClose={handleClosePairDevice}
|
|
testID="open-project-pair-device-modal"
|
|
/>
|
|
<ImportSessionSheet
|
|
visible={isImportSheetOpen}
|
|
client={importClient}
|
|
serverId={importServerId}
|
|
onClose={handleCloseImportSession}
|
|
onImported={handleImported}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
interface HomeTileProps {
|
|
icon: ComponentType<{ size: number; color: string }>;
|
|
title: string;
|
|
description: string;
|
|
onPress: () => void;
|
|
testID?: string;
|
|
accent?: boolean;
|
|
}
|
|
|
|
function HomeTile({ icon: Icon, title, description, onPress, testID, accent }: HomeTileProps) {
|
|
// useUnistyles is acceptable here: leaf component, off the hot path (home screen renders once).
|
|
const { theme } = useUnistyles();
|
|
const [hovered, setHovered] = useState(false);
|
|
const handleHoverIn = useCallback(() => setHovered(true), []);
|
|
const handleHoverOut = useCallback(() => setHovered(false), []);
|
|
|
|
const iconColor = accent ? theme.colors.accent : theme.colors.foregroundMuted;
|
|
|
|
const pressableStyle = useCallback(
|
|
({ pressed }: { pressed: boolean }) => [
|
|
styles.tile,
|
|
hovered && styles.tileHovered,
|
|
pressed && styles.tilePressed,
|
|
],
|
|
[hovered],
|
|
);
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
onHoverIn={handleHoverIn}
|
|
onHoverOut={handleHoverOut}
|
|
testID={testID}
|
|
style={pressableStyle}
|
|
>
|
|
<Icon size={20} color={iconColor} />
|
|
<View style={styles.tileText}>
|
|
<Text style={styles.tileTitle}>{title}</Text>
|
|
<Text style={styles.tileDescription}>{description}</Text>
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: theme.colors.surface0,
|
|
userSelect: "none",
|
|
},
|
|
content: {
|
|
position: "relative",
|
|
flex: 1,
|
|
justifyContent: { xs: "flex-start", md: "center" },
|
|
alignItems: "center",
|
|
gap: 0,
|
|
padding: theme.spacing[6],
|
|
paddingTop: { xs: theme.spacing[12], md: theme.spacing[6] },
|
|
paddingBottom: {
|
|
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
|
|
md: HEADER_INNER_HEIGHT + theme.spacing[6],
|
|
},
|
|
},
|
|
logo: {
|
|
marginBottom: theme.spacing[8],
|
|
},
|
|
tiles: {
|
|
marginTop: { xs: theme.spacing[6], md: theme.spacing[12] },
|
|
width: "100%",
|
|
maxWidth: 452,
|
|
flexDirection: "row",
|
|
flexWrap: "wrap",
|
|
justifyContent: "flex-start",
|
|
gap: theme.spacing[3],
|
|
},
|
|
tile: {
|
|
width: { xs: "100%", md: 220 },
|
|
minHeight: { xs: 0, md: 132 },
|
|
padding: theme.spacing[4],
|
|
backgroundColor: theme.colors.surface1,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
borderRadius: theme.borderRadius.xl,
|
|
gap: theme.spacing[3],
|
|
},
|
|
tileHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
borderColor: theme.colors.borderAccent,
|
|
},
|
|
tilePressed: {
|
|
opacity: 0.85,
|
|
},
|
|
tileText: {
|
|
gap: theme.spacing[1],
|
|
},
|
|
tileTitle: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.base,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
tileDescription: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
lineHeight: 18,
|
|
},
|
|
communityRow: {
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
bottom: {
|
|
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[2],
|
|
md: HEADER_INNER_HEIGHT + theme.spacing[2],
|
|
},
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
gap: 0,
|
|
},
|
|
}));
|