Add welcome onboarding and remove default localhost host
This commit is contained in:
@@ -11,5 +11,5 @@ test('daemon is connected in settings', async ({ page }) => {
|
|||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
|
||||||
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
|
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
|
||||||
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible();
|
await expect(page.getByTestId('daemon-card-e2e-test-daemon').getByText('Online', { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
|
test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||||
|
localStorage.removeItem('@paseo:create-agent-preferences');
|
||||||
|
localStorage.removeItem('@paseo:settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await expect(page.getByText('Welcome to Paseo', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByText('Direct connection', { exact: true }).click();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('My Host').fill('E2E Host');
|
||||||
|
await page.getByPlaceholder('host:6767').fill(`127.0.0.1:${daemonPort}`);
|
||||||
|
|
||||||
|
await page.getByText('Connect & Save', { exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||||
|
await expect(page.getByText('E2E Host', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
@@ -14,16 +14,16 @@ test('manual host add accepts host:port only and persists endpoints', async ({ p
|
|||||||
await page.goto('/settings');
|
await page.goto('/settings');
|
||||||
|
|
||||||
await page.getByText('+ Add Host', { exact: true }).click();
|
await page.getByText('+ Add Host', { exact: true }).click();
|
||||||
await page.getByText('Manual', { exact: true }).click();
|
await page.getByText('Direct connection', { exact: true }).click();
|
||||||
|
|
||||||
const input = page.getByPlaceholder('localhost:6767');
|
const input = page.getByPlaceholder('host:6767');
|
||||||
await expect(input).toBeVisible();
|
await expect(input).toBeVisible();
|
||||||
await input.fill(`127.0.0.1:${daemonPort}`);
|
await input.fill(`127.0.0.1:${daemonPort}`);
|
||||||
|
|
||||||
await page.getByText('Add', { exact: true }).click();
|
await page.getByText('Connect & Save', { exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByText(`127.0.0.1:${daemonPort}`, { exact: true }).first()).toBeVisible();
|
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||||
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 15000 });
|
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(port) => {
|
(port) => {
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ test('pairing flow accepts #offer=ConnectionOfferV1 and stores sessionId + endpo
|
|||||||
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
|
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
|
||||||
|
|
||||||
await page.getByText('+ Add Host', { exact: true }).click();
|
await page.getByText('+ Add Host', { exact: true }).click();
|
||||||
await page.getByText('Pair', { exact: true }).click();
|
await page.getByText('Paste pairing link', { exact: true }).click();
|
||||||
await page.getByText('Paste link', { exact: true }).click();
|
|
||||||
|
|
||||||
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
|
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
|
||||||
await expect(input).toBeVisible();
|
await expect(input).toBeVisible();
|
||||||
@@ -44,7 +43,7 @@ test('pairing flow accepts #offer=ConnectionOfferV1 and stores sessionId + endpo
|
|||||||
|
|
||||||
await page.getByText('Pair', { exact: true }).click();
|
await page.getByText('Pair', { exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByText(`127.0.0.1:${daemonPort}`, { exact: true })).toBeVisible();
|
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||||
|
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
({ expected }) => {
|
({ expected }) => {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ interface AppContainerProps {
|
|||||||
|
|
||||||
function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const { daemons } = useDaemonRegistry();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||||
@@ -67,10 +68,16 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
|||||||
|
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
const chromeEnabled = daemons.length > 0;
|
||||||
|
const isOpen = chromeEnabled
|
||||||
|
? isMobile
|
||||||
|
? mobileView === "agent-list"
|
||||||
|
: desktopAgentListOpen
|
||||||
|
: false;
|
||||||
|
|
||||||
// Cmd+B to toggle sidebar (web only)
|
// Cmd+B to toggle sidebar (web only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!chromeEnabled) return;
|
||||||
if (Platform.OS !== "web") return;
|
if (Platform.OS !== "web") return;
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
|
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
|
||||||
@@ -80,7 +87,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
|||||||
}
|
}
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [toggleAgentList]);
|
}, [chromeEnabled, toggleAgentList]);
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -98,7 +105,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
|||||||
const openGesture = useMemo(
|
const openGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
.enabled(isMobile && !isOpen)
|
.enabled(chromeEnabled && isMobile && !isOpen)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
// Fail if 10px vertical movement happens first (allow vertical scroll)
|
// Fail if 10px vertical movement happens first (allow vertical scroll)
|
||||||
.failOffsetY([-10, 10])
|
.failOffsetY([-10, 10])
|
||||||
@@ -153,7 +160,20 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
|||||||
.onFinalize(() => {
|
.onFinalize(() => {
|
||||||
isGesturing.value = false;
|
isGesturing.value = false;
|
||||||
}),
|
}),
|
||||||
[isMobile, isOpen, windowWidth, translateX, backdropOpacity, animateToOpen, animateToClose, openAgentList, isGesturing, horizontalScroll?.isAnyScrolledRight, touchStartX]
|
[
|
||||||
|
chromeEnabled,
|
||||||
|
isMobile,
|
||||||
|
isOpen,
|
||||||
|
windowWidth,
|
||||||
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
animateToOpen,
|
||||||
|
animateToClose,
|
||||||
|
openAgentList,
|
||||||
|
isGesturing,
|
||||||
|
horizontalScroll?.isAnyScrolledRight,
|
||||||
|
touchStartX,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// When sidebar is collapsed on desktop Tauri macOS, add left padding for traffic lights
|
// When sidebar is collapsed on desktop Tauri macOS, add left padding for traffic lights
|
||||||
@@ -163,12 +183,12 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
|||||||
const content = (
|
const content = (
|
||||||
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
||||||
<View style={{ flex: 1, flexDirection: "row" }}>
|
<View style={{ flex: 1, flexDirection: "row" }}>
|
||||||
{!isMobile && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
{!isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||||
<View style={{ flex: 1, paddingLeft: needsTrafficLightPadding ? trafficLightPadding.left : 0 }}>
|
<View style={{ flex: 1, paddingLeft: needsTrafficLightPadding ? trafficLightPadding.left : 0 }}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{isMobile && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
{isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ import { FileDropZone } from "@/components/file-drop-zone";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
|
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||||
import type {
|
import type {
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
@@ -90,6 +92,7 @@ export default function HomeScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { connectionStates } = useDaemonConnections();
|
const { connectionStates } = useDaemonConnections();
|
||||||
|
const { daemons } = useDaemonRegistry();
|
||||||
const params = useLocalSearchParams<DraftAgentParams>();
|
const params = useLocalSearchParams<DraftAgentParams>();
|
||||||
|
|
||||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||||
@@ -598,6 +601,16 @@ export default function HomeScreen() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (daemons.length === 0) {
|
||||||
|
return (
|
||||||
|
<WelcomeScreen
|
||||||
|
onHostAdded={(profile) => {
|
||||||
|
setSelectedServerIdFromUser(profile.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileDropZone onFilesDropped={handleFilesDropped}>
|
<FileDropZone onFilesDropped={handleFilesDropped}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -617,6 +630,9 @@ export default function HomeScreen() {
|
|||||||
hostEntry?.status === "online" && styles.hostStatusDotOnline,
|
hostEntry?.status === "online" && styles.hostStatusDotOnline,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
{hostStatus ? (
|
||||||
|
<Text style={styles.hostBadgeStatus}>{hostStatus}</Text>
|
||||||
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -842,6 +858,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
},
|
},
|
||||||
|
hostBadgeStatus: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
hostStatusDot: {
|
hostStatusDot: {
|
||||||
width: 6,
|
width: 6,
|
||||||
height: 6,
|
height: 6,
|
||||||
|
|||||||
@@ -156,8 +156,8 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsPairing(true);
|
setIsPairing(true);
|
||||||
await upsertDaemonFromOfferUrl(offerUrl);
|
const profile = await upsertDaemonFromOfferUrl(offerUrl);
|
||||||
router.replace("/settings");
|
router.replace({ pathname: "/", params: { serverId: profile.id } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastScannedRef.current = null;
|
lastScannedRef.current = null;
|
||||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||||
|
|||||||
+103
-218
@@ -22,7 +22,10 @@ import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons
|
|||||||
import { theme as defaultTheme } from "@/styles/theme";
|
import { theme as defaultTheme } from "@/styles/theme";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
import { MenuHeader } from "@/components/headers/menu-header";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { normalizeHostPort } from "@/utils/daemon-endpoints";
|
import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
||||||
|
import { AddHostModal } from "@/components/add-host-modal";
|
||||||
|
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||||
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
|
|
||||||
const delay = (ms: number) =>
|
const delay = (ms: number) =>
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
@@ -379,22 +382,16 @@ export default function SettingsScreen() {
|
|||||||
const {
|
const {
|
||||||
daemons,
|
daemons,
|
||||||
isLoading: daemonLoading,
|
isLoading: daemonLoading,
|
||||||
addDaemon,
|
|
||||||
updateDaemon,
|
updateDaemon,
|
||||||
removeDaemon,
|
removeDaemon,
|
||||||
upsertDaemonFromOfferUrl,
|
|
||||||
} = useDaemonRegistry();
|
} = useDaemonRegistry();
|
||||||
const { connectionStates, updateConnectionStatus } = useDaemonConnections();
|
const { connectionStates, updateConnectionStatus } = useDaemonConnections();
|
||||||
const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false);
|
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
||||||
const [daemonFormMode, setDaemonFormMode] = useState<"choose" | "manual" | "pair">("choose");
|
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
||||||
const [pairSubMode, setPairSubMode] = useState<"choose" | "paste">("choose");
|
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
||||||
const [daemonForm, setDaemonForm] = useState<{
|
const [editingDaemon, setEditingDaemon] = useState<DaemonProfile | null>(null);
|
||||||
id: string | null;
|
const [editLabel, setEditLabel] = useState("");
|
||||||
label: string;
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
endpoint: string;
|
|
||||||
offerUrl: string;
|
|
||||||
}>({ id: null, label: "", endpoint: "", offerUrl: "" });
|
|
||||||
const [isSavingDaemon, setIsSavingDaemon] = useState(false);
|
|
||||||
const isLoading = settingsLoading || daemonLoading;
|
const isLoading = settingsLoading || daemonLoading;
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
@@ -421,95 +418,38 @@ export default function SettingsScreen() {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenDaemonForm = useCallback((profile?: DaemonProfile) => {
|
const handleEditDaemon = useCallback((profile: DaemonProfile) => {
|
||||||
if (profile) {
|
setEditingDaemon(profile);
|
||||||
setDaemonFormMode("manual");
|
setEditLabel(profile.label ?? "");
|
||||||
setPairSubMode("choose");
|
|
||||||
setDaemonForm({
|
|
||||||
id: profile.id,
|
|
||||||
label: profile.label,
|
|
||||||
endpoint: profile.endpoints?.[0] ?? "",
|
|
||||||
offerUrl: "",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setDaemonFormMode("choose");
|
|
||||||
setPairSubMode("choose");
|
|
||||||
setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" });
|
|
||||||
}
|
|
||||||
setIsDaemonFormVisible(true);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCloseDaemonForm = useCallback(() => {
|
const handleCloseEditDaemon = useCallback(() => {
|
||||||
setIsDaemonFormVisible(false);
|
if (isSavingEdit) return;
|
||||||
setDaemonFormMode("choose");
|
setEditingDaemon(null);
|
||||||
setPairSubMode("choose");
|
setEditLabel("");
|
||||||
setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" });
|
}, [isSavingEdit]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmitDaemonForm = useCallback(async () => {
|
const handleSaveEditDaemon = useCallback(async () => {
|
||||||
if (daemonFormMode === "manual") {
|
if (!editingDaemon) return;
|
||||||
const raw = daemonForm.endpoint.trim();
|
if (isSavingEdit) return;
|
||||||
if (raw.includes("://") || raw.includes("/")) {
|
|
||||||
Alert.alert("Invalid host", "Manual hosts must be entered as host:port (no ws://, no /ws).");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let endpoint: string;
|
const nextLabel = editLabel.trim();
|
||||||
try {
|
if (!nextLabel) {
|
||||||
endpoint = normalizeHostPort(raw);
|
Alert.alert("Label required", "Enter a label for this host.");
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Invalid host:port";
|
|
||||||
Alert.alert("Invalid host", message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsSavingDaemon(true);
|
|
||||||
const payload = {
|
|
||||||
label: daemonForm.label.trim(),
|
|
||||||
endpoints: [endpoint],
|
|
||||||
};
|
|
||||||
if (daemonForm.id) {
|
|
||||||
await updateDaemon(daemonForm.id, payload);
|
|
||||||
} else {
|
|
||||||
await addDaemon(payload);
|
|
||||||
}
|
|
||||||
handleCloseDaemonForm();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Settings] Failed to save daemon", error);
|
|
||||||
Alert.alert("Error", "Unable to save host");
|
|
||||||
} finally {
|
|
||||||
setIsSavingDaemon(false);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (daemonFormMode === "pair") {
|
try {
|
||||||
if (pairSubMode !== "paste") {
|
setIsSavingEdit(true);
|
||||||
Alert.alert("Choose a method", "Select Scan QR or Paste link to pair.");
|
await updateDaemon(editingDaemon.id, { label: nextLabel });
|
||||||
return;
|
handleCloseEditDaemon();
|
||||||
}
|
} catch (error) {
|
||||||
const offer = daemonForm.offerUrl.trim();
|
console.error("[Settings] Failed to rename host", error);
|
||||||
if (!offer) {
|
Alert.alert("Error", "Unable to save host");
|
||||||
Alert.alert("Offer required", "Paste the pairing link (…/#offer=...)");
|
} finally {
|
||||||
return;
|
setIsSavingEdit(false);
|
||||||
}
|
|
||||||
try {
|
|
||||||
setIsSavingDaemon(true);
|
|
||||||
await upsertDaemonFromOfferUrl(offer);
|
|
||||||
handleCloseDaemonForm();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Settings] Failed to pair host", error);
|
|
||||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
|
||||||
Alert.alert("Error", message);
|
|
||||||
} finally {
|
|
||||||
setIsSavingDaemon(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [editLabel, editingDaemon, handleCloseEditDaemon, isSavingEdit, updateDaemon]);
|
||||||
Alert.alert("Choose a method", "Select Pair or Manual to add a host.");
|
|
||||||
}, [daemonForm, daemonFormMode, pairSubMode, addDaemon, updateDaemon, upsertDaemonFromOfferUrl, handleCloseDaemonForm]);
|
|
||||||
|
|
||||||
const handleRemoveDaemon = useCallback(
|
const handleRemoveDaemon = useCallback(
|
||||||
(profile: DaemonProfile) => {
|
(profile: DaemonProfile) => {
|
||||||
@@ -642,7 +582,7 @@ export default function SettingsScreen() {
|
|||||||
daemon={daemon}
|
daemon={daemon}
|
||||||
connectionStatus={connectionStatus}
|
connectionStatus={connectionStatus}
|
||||||
lastError={lastConnectionError}
|
lastError={lastConnectionError}
|
||||||
onEdit={handleOpenDaemonForm}
|
onEdit={handleEditDaemon}
|
||||||
onRemove={handleRemoveDaemon}
|
onRemove={handleRemoveDaemon}
|
||||||
restartConfirmationMessage={restartConfirmationMessage}
|
restartConfirmationMessage={restartConfirmationMessage}
|
||||||
waitForCondition={waitForCondition}
|
waitForCondition={waitForCondition}
|
||||||
@@ -652,130 +592,75 @@ export default function SettingsScreen() {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isDaemonFormVisible ? (
|
<Pressable
|
||||||
<View style={styles.formCard}>
|
style={styles.addButton}
|
||||||
<Text style={styles.formTitle}>{daemonForm.id ? "Edit Host" : "Add Host"}</Text>
|
onPress={() => setIsAddHostMethodVisible(true)}
|
||||||
|
>
|
||||||
{daemonForm.id ? (
|
<Text style={styles.addButtonText}>+ Add Host</Text>
|
||||||
<View style={styles.formField}>
|
</Pressable>
|
||||||
<Text style={styles.label}>Label</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={daemonForm.label}
|
|
||||||
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, label: text }))}
|
|
||||||
placeholder="My Host"
|
|
||||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{daemonForm.id ? null : daemonFormMode === "choose" ? (
|
|
||||||
<View style={styles.formActionsRow}>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.formButton, styles.formButtonPrimary]}
|
|
||||||
onPress={() => {
|
|
||||||
setDaemonFormMode("pair");
|
|
||||||
setPairSubMode("choose");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Pair</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.formButton, styles.formButtonPrimary]}
|
|
||||||
onPress={() => setDaemonFormMode("manual")}
|
|
||||||
>
|
|
||||||
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Manual</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{daemonFormMode === "manual" ? (
|
|
||||||
<View style={styles.formField}>
|
|
||||||
<Text style={styles.label}>Host</Text>
|
|
||||||
<TextInput
|
|
||||||
style={[styles.input, styles.inputUrl]}
|
|
||||||
value={daemonForm.endpoint}
|
|
||||||
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, endpoint: text }))}
|
|
||||||
placeholder="localhost:6767"
|
|
||||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
keyboardType="url"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{daemonFormMode === "pair" ? (
|
|
||||||
<View style={styles.formField}>
|
|
||||||
<Text style={styles.label}>Pairing</Text>
|
|
||||||
|
|
||||||
<View style={styles.formActionsRow}>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.formButton, styles.formButtonPrimary]}
|
|
||||||
onPress={() => {
|
|
||||||
handleCloseDaemonForm();
|
|
||||||
router.push("/pair-scan");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Scan QR</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
style={[styles.formButton, styles.formButtonPrimary]}
|
|
||||||
onPress={() => setPairSubMode("paste")}
|
|
||||||
>
|
|
||||||
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Paste link</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{pairSubMode === "paste" ? (
|
|
||||||
<TextInput
|
|
||||||
style={[styles.input, styles.inputUrl, { marginTop: 12 }]}
|
|
||||||
value={daemonForm.offerUrl}
|
|
||||||
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, offerUrl: text }))}
|
|
||||||
placeholder="https://app.paseo.sh/#offer=..."
|
|
||||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
keyboardType="url"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View style={styles.formActionsRow}>
|
|
||||||
<Pressable style={styles.formButton} onPress={handleCloseDaemonForm}>
|
|
||||||
<Text style={styles.formButtonText}>Cancel</Text>
|
|
||||||
</Pressable>
|
|
||||||
{!daemonForm.id && daemonFormMode !== "choose" ? (
|
|
||||||
<Pressable
|
|
||||||
style={styles.formButton}
|
|
||||||
onPress={() => {
|
|
||||||
setDaemonFormMode("choose");
|
|
||||||
setPairSubMode("choose");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={styles.formButtonText}>Back</Text>
|
|
||||||
</Pressable>
|
|
||||||
) : null}
|
|
||||||
<Pressable
|
|
||||||
style={[styles.formButton, styles.formButtonPrimary, isSavingDaemon && styles.hostActionDisabled]}
|
|
||||||
onPress={handleSubmitDaemonForm}
|
|
||||||
disabled={isSavingDaemon}
|
|
||||||
>
|
|
||||||
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>
|
|
||||||
{isSavingDaemon ? "Saving..." : daemonForm.id ? "Save" : daemonFormMode === "pair" ? "Pair" : "Add"}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Pressable style={styles.addButton} onPress={() => handleOpenDaemonForm()}>
|
|
||||||
<Text style={styles.addButtonText}>+ Add Host</Text>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<AddHostMethodModal
|
||||||
|
visible={isAddHostMethodVisible}
|
||||||
|
onClose={() => setIsAddHostMethodVisible(false)}
|
||||||
|
onDirectConnection={() => setIsDirectHostVisible(true)}
|
||||||
|
onPasteLink={() => setIsPasteLinkVisible(true)}
|
||||||
|
onScanQr={() => router.push("/pair-scan")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddHostModal
|
||||||
|
visible={isDirectHostVisible}
|
||||||
|
onClose={() => setIsDirectHostVisible(false)}
|
||||||
|
onSaved={(profile) => {
|
||||||
|
router.replace({ pathname: "/", params: { serverId: profile.id } });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PairLinkModal
|
||||||
|
visible={isPasteLinkVisible}
|
||||||
|
onClose={() => setIsPasteLinkVisible(false)}
|
||||||
|
onSaved={(profile) => {
|
||||||
|
router.replace({ pathname: "/", params: { serverId: profile.id } });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdaptiveModalSheet
|
||||||
|
title="Edit host"
|
||||||
|
visible={Boolean(editingDaemon)}
|
||||||
|
onClose={handleCloseEditDaemon}
|
||||||
|
testID="edit-host-modal"
|
||||||
|
>
|
||||||
|
<View style={styles.formField}>
|
||||||
|
<Text style={styles.label}>Label</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={editLabel}
|
||||||
|
onChangeText={setEditLabel}
|
||||||
|
placeholder="My Host"
|
||||||
|
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.formActionsRow}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.formButton, isSavingEdit && styles.hostActionDisabled]}
|
||||||
|
onPress={handleCloseEditDaemon}
|
||||||
|
disabled={isSavingEdit}
|
||||||
|
>
|
||||||
|
<Text style={styles.formButtonText}>Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.formButton, styles.formButtonPrimary, isSavingEdit && styles.hostActionDisabled]}
|
||||||
|
onPress={() => void handleSaveEditDaemon()}
|
||||||
|
disabled={isSavingEdit}
|
||||||
|
>
|
||||||
|
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>
|
||||||
|
{isSavingEdit ? "Saving..." : "Save"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</AdaptiveModalSheet>
|
||||||
|
|
||||||
{/* Appearance */}
|
{/* Appearance */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Appearance</Text>
|
<Text style={styles.sectionTitle}>Appearance</Text>
|
||||||
@@ -1037,7 +922,7 @@ function DaemonCard({
|
|||||||
: "rgba(161, 161, 170, 0.1)";
|
: "rgba(161, 161, 170, 0.1)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.hostCard}>
|
<View style={styles.hostCard} testID={`daemon-card-${daemon.id}`}>
|
||||||
<View style={styles.hostCardContent}>
|
<View style={styles.hostCardContent}>
|
||||||
<View style={styles.hostHeaderRow}>
|
<View style={styles.hostHeaderRow}>
|
||||||
<Text style={styles.hostLabel}>{daemon.label}</Text>
|
<Text style={styles.hostLabel}>{daemon.label}</Text>
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
|
import {
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetScrollView,
|
||||||
|
type BottomSheetBackgroundProps,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { X } from "lucide-react-native";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
desktopOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.55)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
},
|
||||||
|
desktopCard: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 520,
|
||||||
|
maxHeight: "85%",
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.surface2,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: theme.spacing[6],
|
||||||
|
paddingVertical: theme.spacing[4],
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.lg,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
desktopScroll: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
desktopContent: {
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
},
|
||||||
|
bottomSheetHandle: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
bottomSheetHeader: {
|
||||||
|
paddingHorizontal: theme.spacing[6],
|
||||||
|
paddingTop: theme.spacing[4],
|
||||||
|
paddingBottom: theme.spacing[3],
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
bottomSheetContent: {
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
style,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
borderTopLeftRadius: theme.borderRadius.xl,
|
||||||
|
borderTopRightRadius: theme.borderRadius.xl,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdaptiveModalSheetProps {
|
||||||
|
title: string;
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
snapPoints?: string[];
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdaptiveModalSheet({
|
||||||
|
title,
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
snapPoints,
|
||||||
|
testID,
|
||||||
|
}: AdaptiveModalSheetProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const isMobile =
|
||||||
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
const sheetRef = useRef<BottomSheetModal>(null);
|
||||||
|
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile) return;
|
||||||
|
if (visible) {
|
||||||
|
sheetRef.current?.present();
|
||||||
|
} else {
|
||||||
|
sheetRef.current?.dismiss();
|
||||||
|
}
|
||||||
|
}, [visible, isMobile]);
|
||||||
|
|
||||||
|
const handleSheetChange = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
opacity={0.45}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={sheetRef}
|
||||||
|
snapPoints={resolvedSnapPoints}
|
||||||
|
index={0}
|
||||||
|
enableDynamicSizing={false}
|
||||||
|
onChange={handleSheetChange}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
enablePanDownToClose
|
||||||
|
backgroundComponent={SheetBackground}
|
||||||
|
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||||
|
keyboardBehavior="extend"
|
||||||
|
keyboardBlurBehavior="restore"
|
||||||
|
>
|
||||||
|
<View style={styles.bottomSheetHeader}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Close"
|
||||||
|
style={styles.closeButton}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<X size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<BottomSheetScrollView
|
||||||
|
contentContainerStyle={styles.bottomSheetContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
visible={visible}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
hardwareAccelerated
|
||||||
|
>
|
||||||
|
<View style={styles.desktopOverlay} testID={testID}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Dismiss"
|
||||||
|
style={{ ...StyleSheet.absoluteFillObject }}
|
||||||
|
onPress={onClose}
|
||||||
|
/>
|
||||||
|
<View style={styles.desktopCard}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Close"
|
||||||
|
style={styles.closeButton}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<X size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.desktopScroll}
|
||||||
|
contentContainerStyle={styles.desktopContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { Pressable, Text, View, Platform } from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||||
|
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
option: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
optionText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
optionSubtext: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
},
|
||||||
|
optionBody: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface AddHostMethodModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onDirectConnection: () => void;
|
||||||
|
onScanQr: () => void;
|
||||||
|
onPasteLink: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddHostMethodModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onDirectConnection,
|
||||||
|
onScanQr,
|
||||||
|
onPasteLink,
|
||||||
|
}: AddHostMethodModalProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
const handleDirect = useCallback(() => {
|
||||||
|
onClose();
|
||||||
|
onDirectConnection();
|
||||||
|
}, [onClose, onDirectConnection]);
|
||||||
|
|
||||||
|
const handleScan = useCallback(() => {
|
||||||
|
onClose();
|
||||||
|
onScanQr();
|
||||||
|
}, [onClose, onScanQr]);
|
||||||
|
|
||||||
|
const handlePaste = useCallback(() => {
|
||||||
|
onClose();
|
||||||
|
onPasteLink();
|
||||||
|
}, [onClose, onPasteLink]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdaptiveModalSheet title="Add host" visible={visible} onClose={onClose} testID="add-host-method-modal">
|
||||||
|
<Pressable style={styles.option} onPress={handleDirect} accessibilityLabel="Direct connection">
|
||||||
|
<Link2 size={18} color={theme.colors.foreground} />
|
||||||
|
<View style={styles.optionBody}>
|
||||||
|
<Text style={styles.optionText}>Direct connection</Text>
|
||||||
|
<Text style={styles.optionSubtext}>Enter a host and port.</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{Platform.OS !== "web" ? (
|
||||||
|
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
|
||||||
|
<QrCode size={18} color={theme.colors.foreground} />
|
||||||
|
<View style={styles.optionBody}>
|
||||||
|
<Text style={styles.optionText}>Scan QR code</Text>
|
||||||
|
<Text style={styles.optionSubtext}>Use your camera to pair.</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Pressable style={styles.option} onPress={handlePaste} accessibilityLabel="Paste pairing link">
|
||||||
|
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||||
|
<View style={styles.optionBody}>
|
||||||
|
<Text style={styles.optionText}>Paste pairing link</Text>
|
||||||
|
<Text style={styles.optionSubtext}>Works without a camera.</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</AdaptiveModalSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { Alert, Pressable, Text, TextInput, View } from "react-native";
|
||||||
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||||
|
import { Link2 } from "lucide-react-native";
|
||||||
|
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
||||||
|
import { normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
|
import { DaemonConnectionTestError, testDaemonEndpointConnection } from "@/utils/test-daemon-connection";
|
||||||
|
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
marginTop: theme.spacing[2],
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
backgroundColor: theme.colors.palette.blue[500],
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
color: theme.colors.palette.white,
|
||||||
|
},
|
||||||
|
helper: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: theme.colors.destructive,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
connectRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function isHostPortOnly(raw: string): boolean {
|
||||||
|
return !raw.includes("://") && !raw.includes("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTransportMessage(message: string | null | undefined): string | null {
|
||||||
|
if (!message) return null;
|
||||||
|
const trimmed = message.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTechnicalTransportDetails(details: Array<string | null>): string | null {
|
||||||
|
const unique = Array.from(
|
||||||
|
new Set(
|
||||||
|
details
|
||||||
|
.map((value) => normalizeTransportMessage(value))
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value) => value.length > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (unique.length === 0) return null;
|
||||||
|
|
||||||
|
const allGeneric = unique.every((value) => {
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
return lower === "transport error" || lower === "transport closed";
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allGeneric) {
|
||||||
|
return `${unique[0]} (no additional details provided)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return unique.join(" — ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConnectionFailureCopy(endpoint: string, error: unknown): { title: string; detail: string | null; raw: string | null } {
|
||||||
|
const title = `We failed to connect to ${endpoint}.`;
|
||||||
|
|
||||||
|
const raw = (() => {
|
||||||
|
if (error instanceof DaemonConnectionTestError) {
|
||||||
|
return (
|
||||||
|
formatTechnicalTransportDetails([error.reason, error.lastError]) ??
|
||||||
|
normalizeTransportMessage(error.message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return normalizeTransportMessage(error.message);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const rawLower = raw?.toLowerCase() ?? "";
|
||||||
|
let detail: string | null = null;
|
||||||
|
|
||||||
|
if (rawLower.includes("timed out")) {
|
||||||
|
detail = "Connection timed out. Check the host/port and your network.";
|
||||||
|
} else if (
|
||||||
|
rawLower.includes("econnrefused") ||
|
||||||
|
rawLower.includes("connection refused") ||
|
||||||
|
rawLower.includes("err_connection_refused")
|
||||||
|
) {
|
||||||
|
detail = "Connection was refused. Is the daemon running on that host and port?";
|
||||||
|
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||||
|
detail = "Host not found. Check the hostname and try again.";
|
||||||
|
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||||
|
detail = "Host is unreachable. Check your network and firewall.";
|
||||||
|
} else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) {
|
||||||
|
detail = "TLS/certificate error. This app expects a daemon reachable over the local network or via relay.";
|
||||||
|
} else if (raw) {
|
||||||
|
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
|
||||||
|
} else {
|
||||||
|
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title, detail, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddHostModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved?: (profile: HostProfile) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const { addDaemon } = useDaemonRegistry();
|
||||||
|
const isMobile =
|
||||||
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
|
const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
|
||||||
|
const labelInputRef = useRef<TextInput>(null);
|
||||||
|
const hostInputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [endpointRaw, setEndpointRaw] = useState("");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (isSaving) return;
|
||||||
|
setLabel("");
|
||||||
|
setEndpointRaw("");
|
||||||
|
setErrorMessage("");
|
||||||
|
onClose();
|
||||||
|
}, [isSaving, onClose]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (isSaving) return;
|
||||||
|
|
||||||
|
const raw = endpointRaw.trim();
|
||||||
|
if (!raw) {
|
||||||
|
setErrorMessage("Host is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isHostPortOnly(raw)) {
|
||||||
|
setErrorMessage("Enter host:port only (no ws://, no /ws)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let endpoint: string;
|
||||||
|
try {
|
||||||
|
endpoint = normalizeHostPort(raw);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Invalid host:port";
|
||||||
|
setErrorMessage(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
setErrorMessage("");
|
||||||
|
|
||||||
|
await testDaemonEndpointConnection(endpoint);
|
||||||
|
|
||||||
|
const profile = await addDaemon({
|
||||||
|
label: label.trim(),
|
||||||
|
endpoints: [endpoint],
|
||||||
|
});
|
||||||
|
|
||||||
|
onSaved?.(profile);
|
||||||
|
handleClose();
|
||||||
|
} catch (error) {
|
||||||
|
const { title, detail, raw } = buildConnectionFailureCopy(endpoint, error);
|
||||||
|
const combined =
|
||||||
|
raw && detail && raw !== detail
|
||||||
|
? `${title}\n${detail}\nDetails: ${raw}`
|
||||||
|
: detail
|
||||||
|
? `${title}\n${detail}`
|
||||||
|
: title;
|
||||||
|
setErrorMessage(combined);
|
||||||
|
if (!isMobile) {
|
||||||
|
// Desktop/web: also surface it as a dialog for quick visibility.
|
||||||
|
Alert.alert("Connection failed", combined);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [addDaemon, endpointRaw, handleClose, isMobile, isSaving, label, onSaved]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
|
||||||
|
<Text style={styles.helper}>Connect to a daemon by entering host:port.</Text>
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Label (optional)</Text>
|
||||||
|
<InputComponent
|
||||||
|
ref={labelInputRef as any}
|
||||||
|
value={label}
|
||||||
|
onChangeText={setLabel}
|
||||||
|
placeholder="My Host"
|
||||||
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
|
style={styles.input}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
editable={!isSaving}
|
||||||
|
returnKeyType="next"
|
||||||
|
blurOnSubmit={false}
|
||||||
|
onSubmitEditing={() => hostInputRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Host</Text>
|
||||||
|
<InputComponent
|
||||||
|
ref={hostInputRef as any}
|
||||||
|
value={endpointRaw}
|
||||||
|
onChangeText={setEndpointRaw}
|
||||||
|
placeholder="host:6767"
|
||||||
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
|
style={styles.input}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
editable={!isSaving}
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={() => void handleSave()}
|
||||||
|
/>
|
||||||
|
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving}>
|
||||||
|
<Text style={styles.buttonText}>Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.button, styles.primaryButton, isSaving ? { opacity: 0.7 } : null]}
|
||||||
|
onPress={() => void handleSave()}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<View style={styles.connectRow}>
|
||||||
|
<Link2 size={16} color={theme.colors.palette.white} />
|
||||||
|
<Text style={[styles.buttonText, styles.primaryButtonText]}>
|
||||||
|
{isSaving ? "Connecting..." : "Connect & Save"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</AdaptiveModalSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,12 +58,15 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
|||||||
|
|
||||||
const candidates = useMemo(() => buildCandidateUrls(daemon), [daemon]);
|
const candidates = useMemo(() => buildCandidateUrls(daemon), [daemon]);
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const activeUrl = candidates[activeIndex] ?? candidates[0] ?? buildDaemonWebSocketUrl("localhost:6767");
|
const activeUrl = candidates[activeIndex] ?? candidates[0] ?? null;
|
||||||
|
|
||||||
const lastAttemptedUrlRef = useRef<string | null>(null);
|
const lastAttemptedUrlRef = useRef<string | null>(null);
|
||||||
const pendingMetadataWriteRef = useRef(false);
|
const pendingMetadataWriteRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!activeUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// If the active URL fell out of the candidate set (e.g. endpoints updated), snap back.
|
// If the active URL fell out of the candidate set (e.g. endpoints updated), snap back.
|
||||||
const idx = candidates.indexOf(activeUrl);
|
const idx = candidates.indexOf(activeUrl);
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
@@ -72,6 +75,10 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [candidates.join("|")]);
|
}, [candidates.join("|")]);
|
||||||
|
|
||||||
|
if (!activeUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const connection = connectionStates.get(daemon.id);
|
const connection = connectionStates.get(daemon.id);
|
||||||
const status = connection?.status ?? "idle";
|
const status = connection?.status ?? "idle";
|
||||||
const lastError = connection?.lastError ?? null;
|
const lastError = connection?.lastError ?? null;
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Alert, Pressable, Text, TextInput, View } from "react-native";
|
||||||
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||||
|
import { Link } from "lucide-react-native";
|
||||||
|
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
||||||
|
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||||
|
|
||||||
|
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],
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
backgroundColor: theme.colors.palette.blue[500],
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
color: theme.colors.palette.white,
|
||||||
|
},
|
||||||
|
connectRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface PairLinkModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved?: (profile: HostProfile) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const { upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
||||||
|
const isMobile =
|
||||||
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
|
||||||
|
|
||||||
|
const [offerUrl, setOfferUrl] = useState("");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (isSaving) return;
|
||||||
|
setOfferUrl("");
|
||||||
|
setErrorMessage("");
|
||||||
|
onClose();
|
||||||
|
}, [isSaving, onClose]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (isSaving) return;
|
||||||
|
const raw = offerUrl.trim();
|
||||||
|
if (!raw) {
|
||||||
|
setErrorMessage("Paste a pairing link (…/#offer=...)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!raw.includes("#offer=")) {
|
||||||
|
setErrorMessage("Link must include #offer=...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
setErrorMessage("");
|
||||||
|
const profile = await upsertDaemonFromOfferUrl(raw);
|
||||||
|
onSaved?.(profile);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, [handleClose, isMobile, isSaving, offerUrl, onSaved, upsertDaemonFromOfferUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal">
|
||||||
|
<Text style={styles.helper}>Paste the daemon’s pairing link.</Text>
|
||||||
|
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>Pairing link</Text>
|
||||||
|
<InputComponent
|
||||||
|
value={offerUrl}
|
||||||
|
onChangeText={setOfferUrl}
|
||||||
|
placeholder="https://app.paseo.sh/#offer=..."
|
||||||
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
|
style={styles.input}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
/>
|
||||||
|
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving}>
|
||||||
|
<Text style={styles.buttonText}>Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.button, styles.primaryButton, isSaving ? { opacity: 0.7 } : null]}
|
||||||
|
onPress={() => void handleSave()}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<View style={styles.connectRow}>
|
||||||
|
<Link size={16} color={theme.colors.palette.white} />
|
||||||
|
<Text style={[styles.buttonText, styles.primaryButtonText]}>
|
||||||
|
{isSaving ? "Pairing..." : "Pair"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</AdaptiveModalSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Image, Pressable, Text, View, Platform } from "react-native";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { QrCode, Link2 } from "lucide-react-native";
|
||||||
|
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
||||||
|
import { AddHostModal } from "./add-host-modal";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
padding: theme.spacing[8],
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
marginBottom: theme.spacing[6],
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.xl,
|
||||||
|
fontWeight: theme.fontWeight.bold,
|
||||||
|
marginBottom: theme.spacing[3],
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: theme.spacing[8],
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
actionButtonPrimary: {
|
||||||
|
backgroundColor: theme.colors.palette.blue[500],
|
||||||
|
borderColor: theme.colors.palette.blue[500],
|
||||||
|
},
|
||||||
|
actionText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
actionTextPrimary: {
|
||||||
|
color: theme.colors.palette.white,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface WelcomeScreenProps {
|
||||||
|
onHostAdded?: (profile: HostProfile) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID="welcome-screen">
|
||||||
|
<Image
|
||||||
|
source={require("../../assets/images/icon.png")}
|
||||||
|
style={styles.logo}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||||
|
<Text style={styles.subtitle}>Add a host to start.</Text>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.actionButton, styles.actionButtonPrimary]}
|
||||||
|
onPress={() => setIsDirectOpen(true)}
|
||||||
|
>
|
||||||
|
<Link2 size={18} color={theme.colors.palette.white} />
|
||||||
|
<Text style={[styles.actionText, styles.actionTextPrimary]}>Direct connection</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{Platform.OS !== "web" ? (
|
||||||
|
<Pressable
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => router.push("/pair-scan")}
|
||||||
|
>
|
||||||
|
<QrCode size={18} color={theme.colors.foreground} />
|
||||||
|
<Text style={styles.actionText}>Scan QR code</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<AddHostModal
|
||||||
|
visible={isDirectOpen}
|
||||||
|
onClose={() => setIsDirectOpen(false)}
|
||||||
|
onSaved={(profile) => {
|
||||||
|
onHostAdded?.(profile);
|
||||||
|
router.replace({ pathname: "/", params: { serverId: profile.id } });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,10 +13,6 @@ import {
|
|||||||
|
|
||||||
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||||
const FALLBACK_DAEMON_ENDPOINT = "localhost:6767";
|
|
||||||
const DEFAULT_HOSTS: Array<{ label: string; endpoint: string }> = [
|
|
||||||
{ label: "localhost", endpoint: "localhost:6767" },
|
|
||||||
];
|
|
||||||
const DAEMON_REGISTRY_QUERY_KEY = ["daemon-registry"];
|
const DAEMON_REGISTRY_QUERY_KEY = ["daemon-registry"];
|
||||||
|
|
||||||
export type HostRelayConfig = {
|
export type HostRelayConfig = {
|
||||||
@@ -400,11 +396,8 @@ async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
|
|||||||
return envDefaults;
|
return envDefaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallback = DEFAULT_HOSTS.length > 0
|
// No implicit localhost fallback: a fresh install starts with zero hosts.
|
||||||
? DEFAULT_HOSTS.map((entry) => createProfile(entry.label, entry.endpoint))
|
return [];
|
||||||
: [createProfile("Local Host", FALLBACK_DAEMON_ENDPOINT)];
|
|
||||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(fallback));
|
|
||||||
return fallback;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[DaemonRegistry] Failed to load daemon registry", error);
|
console.error("[DaemonRegistry] Failed to load daemon registry", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -412,6 +405,9 @@ async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildDirectDaemonWsUrl(profile: HostProfile): string {
|
export function buildDirectDaemonWsUrl(profile: HostProfile): string {
|
||||||
const endpoint = profile.endpoints[0] ?? FALLBACK_DAEMON_ENDPOINT;
|
const endpoint = profile.endpoints[0];
|
||||||
|
if (!endpoint) {
|
||||||
|
throw new Error("Host profile has no endpoints");
|
||||||
|
}
|
||||||
return buildDaemonWebSocketUrl(endpoint);
|
return buildDaemonWebSocketUrl(endpoint);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||||
|
import type { ConnectionState } from "@server/client/daemon-client-v2";
|
||||||
|
import { buildDaemonWebSocketUrl } from "./daemon-endpoints";
|
||||||
|
|
||||||
|
function normalizeNonEmptyString(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestReason(reason: string | null, lastError: string | null): string {
|
||||||
|
const genericReason =
|
||||||
|
reason &&
|
||||||
|
(reason.toLowerCase() === "transport error" || reason.toLowerCase() === "transport closed");
|
||||||
|
const genericLastError =
|
||||||
|
lastError &&
|
||||||
|
(lastError.toLowerCase() === "transport error" ||
|
||||||
|
lastError.toLowerCase() === "transport closed" ||
|
||||||
|
lastError.toLowerCase() === "unable to connect");
|
||||||
|
|
||||||
|
if (genericReason && lastError && !genericLastError) {
|
||||||
|
return lastError;
|
||||||
|
}
|
||||||
|
if (reason) return reason;
|
||||||
|
if (lastError) return lastError;
|
||||||
|
return "Unable to connect";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DaemonConnectionTestError extends Error {
|
||||||
|
reason: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
|
||||||
|
constructor(message: string, details: { reason: string | null; lastError: string | null }) {
|
||||||
|
super(message);
|
||||||
|
this.name = "DaemonConnectionTestError";
|
||||||
|
this.reason = details.reason;
|
||||||
|
this.lastError = details.lastError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testDaemonEndpointConnection(
|
||||||
|
endpoint: string,
|
||||||
|
options?: { timeoutMs?: number }
|
||||||
|
): Promise<void> {
|
||||||
|
const timeoutMs = options?.timeoutMs ?? 6000;
|
||||||
|
const url = buildDaemonWebSocketUrl(endpoint);
|
||||||
|
|
||||||
|
const client = new DaemonClientV2({
|
||||||
|
url,
|
||||||
|
suppressSendErrors: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let cleanedUp = false;
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishOk = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishErr = (error: Error) => {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
finishErr(
|
||||||
|
new DaemonConnectionTestError("Connection timed out", {
|
||||||
|
reason: "Connection timed out",
|
||||||
|
lastError: client.lastError ?? null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
unsubscribe = client.subscribeConnectionStatus((state) => {
|
||||||
|
if (state.status === "connected") {
|
||||||
|
finishOk();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.status === "disconnected") {
|
||||||
|
const reason = normalizeNonEmptyString(state.reason);
|
||||||
|
const lastError = normalizeNonEmptyString(client.lastError);
|
||||||
|
const message = pickBestReason(reason, lastError);
|
||||||
|
finishErr(new DaemonConnectionTestError(message, { reason, lastError }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void client.connect().catch(() => undefined);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -223,6 +223,7 @@ export class DaemonClientV2 {
|
|||||||
private connectionListeners: Set<(status: ConnectionState) => void> =
|
private connectionListeners: Set<(status: ConnectionState) => void> =
|
||||||
new Set();
|
new Set();
|
||||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private pendingGenericTransportErrorTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private reconnectAttempt = 0;
|
private reconnectAttempt = 0;
|
||||||
private shouldReconnect = true;
|
private shouldReconnect = true;
|
||||||
private connectPromise: Promise<void> | null = null;
|
private connectPromise: Promise<void> | null = null;
|
||||||
@@ -296,12 +297,20 @@ export class DaemonClientV2 {
|
|||||||
|
|
||||||
this.transportCleanup = [
|
this.transportCleanup = [
|
||||||
transport.onOpen(() => {
|
transport.onOpen(() => {
|
||||||
|
if (this.pendingGenericTransportErrorTimeout) {
|
||||||
|
clearTimeout(this.pendingGenericTransportErrorTimeout);
|
||||||
|
this.pendingGenericTransportErrorTimeout = null;
|
||||||
|
}
|
||||||
this.lastErrorValue = null;
|
this.lastErrorValue = null;
|
||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
this.updateConnectionState({ status: "connected" });
|
this.updateConnectionState({ status: "connected" });
|
||||||
this.resolveConnect();
|
this.resolveConnect();
|
||||||
}),
|
}),
|
||||||
transport.onClose((event) => {
|
transport.onClose((event) => {
|
||||||
|
if (this.pendingGenericTransportErrorTimeout) {
|
||||||
|
clearTimeout(this.pendingGenericTransportErrorTimeout);
|
||||||
|
this.pendingGenericTransportErrorTimeout = null;
|
||||||
|
}
|
||||||
const reason = describeTransportClose(event);
|
const reason = describeTransportClose(event);
|
||||||
if (reason) {
|
if (reason) {
|
||||||
this.lastErrorValue = reason;
|
this.lastErrorValue = reason;
|
||||||
@@ -314,11 +323,34 @@ export class DaemonClientV2 {
|
|||||||
}),
|
}),
|
||||||
transport.onError((event) => {
|
transport.onError((event) => {
|
||||||
const reason = describeTransportError(event);
|
const reason = describeTransportError(event);
|
||||||
|
const isGeneric = reason === "Transport error";
|
||||||
|
// Browser WebSocket.onerror often provides no useful details and is followed
|
||||||
|
// by a close event (often with code 1006). Prefer surfacing the close details
|
||||||
|
// instead of immediately disconnecting with a generic "Transport error".
|
||||||
|
if (isGeneric) {
|
||||||
|
this.lastErrorValue ??= reason;
|
||||||
|
if (!this.pendingGenericTransportErrorTimeout) {
|
||||||
|
this.pendingGenericTransportErrorTimeout = setTimeout(() => {
|
||||||
|
this.pendingGenericTransportErrorTimeout = null;
|
||||||
|
if (
|
||||||
|
this.connectionState.status === "connected" ||
|
||||||
|
this.connectionState.status === "connecting"
|
||||||
|
) {
|
||||||
|
this.lastErrorValue = reason;
|
||||||
|
this.updateConnectionState({ status: "disconnected", reason });
|
||||||
|
this.scheduleReconnect(reason);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pendingGenericTransportErrorTimeout) {
|
||||||
|
clearTimeout(this.pendingGenericTransportErrorTimeout);
|
||||||
|
this.pendingGenericTransportErrorTimeout = null;
|
||||||
|
}
|
||||||
this.lastErrorValue = reason;
|
this.lastErrorValue = reason;
|
||||||
this.updateConnectionState({
|
this.updateConnectionState({ status: "disconnected", reason });
|
||||||
status: "disconnected",
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
this.scheduleReconnect(reason);
|
this.scheduleReconnect(reason);
|
||||||
}),
|
}),
|
||||||
transport.onMessage((data) => this.handleTransportMessage(data)),
|
transport.onMessage((data) => this.handleTransportMessage(data)),
|
||||||
@@ -1928,6 +1960,10 @@ export class DaemonClientV2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private cleanupTransport(): void {
|
private cleanupTransport(): void {
|
||||||
|
if (this.pendingGenericTransportErrorTimeout) {
|
||||||
|
clearTimeout(this.pendingGenericTransportErrorTimeout);
|
||||||
|
this.pendingGenericTransportErrorTimeout = null;
|
||||||
|
}
|
||||||
for (const cleanup of this.transportCleanup) {
|
for (const cleanup of this.transportCleanup) {
|
||||||
try {
|
try {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|||||||
Reference in New Issue
Block a user