Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { OverlayPortalHost } from "./components/ui/OverlayPortal";
import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking";
import { hasCloudPublicConfig } from "./features/cloud/publicConfig";
import { takeScheduledTaskNewLink } from "./features/settings/scheduled-task-editor-state";
import { startDevAutoConnect } from "./features/connection/devAutoConnect";
import {
resolvePendingNewTaskPromptLink,
takeNewTaskPromptLink,
Expand Down Expand Up @@ -103,11 +104,13 @@ const linkingConfig = {

function getAppStateFromPath(path: string, options: Parameters<typeof getStateFromPath>[1]) {
return takeScheduledTaskNewLink(
takeNewTaskPromptLink(
takeThreadPromptLink(
restoreSettingsLinkState(
restoreDraftLinkState(getStateFromPath(path, options), path),
settingsLinkPage(),
startDevAutoConnect(
takeNewTaskPromptLink(
takeThreadPromptLink(
restoreSettingsLinkState(
restoreDraftLinkState(getStateFromPath(path, options), path),
settingsLinkPage(),
),
),
),
),
Expand Down
104 changes: 104 additions & 0 deletions apps/mobile/src/features/connection/ConnectionsNewRouteScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { ReactNode } from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vite-plus/test";

const { Children, harness } = vi.hoisted(() => ({
Children: ({ children }: { children?: ReactNode }) => <>{children}</>,
harness: {
// The app-wide error slot other features (composer sends, branch refreshes) write to.
pendingConnectionError: null as string | null,
connectPress: vi.fn(),
},
}));

vi.mock("react-native", () => ({
View: Children,
Pressable: Children,
Linking: { openSettings: vi.fn() },
StyleSheet: { absoluteFill: {} },
}));
vi.mock("react-native-svg", () => ({
default: Children,
Defs: Children,
RadialGradient: Children,
Rect: () => null,
Stop: () => null,
}));
vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) }));
vi.mock("@react-navigation/native", () => ({
StackActions: { replace: vi.fn() },
useIsFocused: () => true,
useNavigation: () => ({ canGoBack: () => true, goBack: vi.fn(), dispatch: vi.fn() }),
useRoute: () => ({ name: "ConnectionsNew" }),
}));
vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
vi.mock("@clerk/expo", () => ({ useAuth: () => ({ isLoaded: false, isSignedIn: false }) }));
vi.mock("expo-camera", () => ({
CameraView: () => null,
useCameraPermissions: () => [null, vi.fn()],
}));
vi.mock("expo-haptics", () => ({
notificationAsync: vi.fn(async () => {}),
NotificationFeedbackType: { Success: "success", Error: "error" },
}));
vi.mock("../../components/ui/ListRow", () => ({ ListRow: () => null }));
vi.mock("../../components/ui/Dialog", () => ({ alert: vi.fn() }));
vi.mock("../../components/ui/ScreenScrollView", () => ({ ScreenScrollView: Children }));
vi.mock("../../components/ui/AppText", () => ({
AppText: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
AppTextInput: () => null,
}));
vi.mock("../../components/ui/FrostedCutout", () => ({ FrostedCutout: () => null }));
vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: () => null }));
vi.mock("../../lib/useUniwindTheme", () => ({ useUniwindTheme: () => ({}) }));
vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({
useAppearancePreferences: () => ({ themeAppearance: "dark" }),
}));
vi.mock("../settings/components/SettingsScreen", () => ({ SettingsScreen: Children }));
vi.mock("../settings/settings-navigation", () => ({
leaveSettingsPage: vi.fn(),
openSettings: vi.fn(),
}));
vi.mock("../workspace/workspace-selection-store", () => ({ workspaceHasColumns: () => false }));
vi.mock("../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false }));
vi.mock("./CloudEnvironmentRows", () => ({ CloudEnvironmentRows: () => null }));
vi.mock("./environmentSections", () => ({
splitEnvironmentSections: () => ({ connectedCloudEnvironments: [] }),
}));
vi.mock("./devAutoConnect", () => ({
autoConnectReport: () => ({ connecting: false, error: null }),
devAutoConnectAttemptAtom: {},
}));
vi.mock("../../state/use-remote-environment-registry", () => ({
setPendingConnectionError: (message: string | null) => {
harness.pendingConnectionError = message;
},
useRemoteConnections: () => ({
connectionPairingUrl: "",
connectedEnvironments: [],
pairingConnectionError: harness.pendingConnectionError,
onChangeConnectionPairingUrl: vi.fn(),
onConnectPress: harness.connectPress,
onSetEnvironmentEnabled: vi.fn(),
onRemoveEnvironmentPress: vi.fn(),
}),
}));

import { ConnectionsNewRouteScreen } from "./ConnectionsNewRouteScreen";

it("does not show another feature's error as a pairing failure", () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal("__DEV__", false);
harness.pendingConnectionError = "Could not send the message.";
const container = document.createElement("div");
const root = createRoot(container);
act(() => root.render(<ConnectionsNewRouteScreen route={{ params: undefined }} />));

expect(container.textContent).not.toContain("Could not send the message.");
expect(container.textContent).toContain("For machines on your local network or tailnet.");

act(() => root.unmount());
// Leaving the form must not wipe an error that belongs to another screen.
expect(harness.pendingConnectionError).toBe("Could not send the message.");
});
124 changes: 55 additions & 69 deletions apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
type StaticScreenProps,
} from "@react-navigation/native";
import type { EnvironmentId } from "@t3tools/contracts";
import { AsyncResult } from "effect/reactivity";
import { useEffect, useRef, useState } from "react";
import { useAtomValue } from "@effect/atom-react";
import { useState } from "react";
import { Linking, Pressable, StyleSheet, View } from "react-native";
import Svg, { Defs, RadialGradient, Rect, Stop } from "react-native-svg";
import { useSafeAreaInsets } from "react-native-safe-area-context";
Expand All @@ -24,13 +24,11 @@ import { SettingsScreen } from "../settings/components/SettingsScreen";
import { AppText as Text, AppTextInput, type AppTextInputProps } from "../../components/ui/AppText";
import { FrostedCutout } from "../../components/ui/FrostedCutout";
import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing";
import {
setPendingConnectionError,
useRemoteConnections,
} from "../../state/use-remote-environment-registry";
import { useRemoteConnections } from "../../state/use-remote-environment-registry";
import { SymbolView } from "../../components/ui/AppSymbol";
import { hasCloudPublicConfig } from "../cloud/publicConfig";
import { CloudEnvironmentRows } from "./CloudEnvironmentRows";
import { autoConnectReport, devAutoConnectAttemptAtom } from "./devAutoConnect";
import { splitEnvironmentSections } from "./environmentSections";
import { leaveSettingsPage } from "../settings/settings-navigation";
import { workspaceHasColumns } from "../workspace/workspace-selection-store";
Expand All @@ -45,73 +43,70 @@ type ConnectionsNewRouteParams = {
export function ConnectionsNewRouteScreen({
route,
}: StaticScreenProps<ConnectionsNewRouteParams | undefined>) {
const {
connectionPairingUrl,
onChangeConnectionPairingUrl,
onConnectPress,
pairingConnectionError,
} = useRemoteConnections();
const navigation = useNavigation();
const routeName = useRoute().name;
const params = route.params ?? {};
// Dev-only prefill prevents production links from supplying an attacker-chosen host and token.
const routePairingUrl = __DEV__ ? (params.pairingUrl?.trim() ?? "") : "";
const shouldAutoConnect =
__DEV__ &&
routePairingUrl.length > 0 &&
(params.autoConnect === "1" || params.autoConnect === "true");
// A new prefill link replaces whatever the form held.
return (
<PairingForm
key={routePairingUrl}
routePairingUrl={routePairingUrl}
routeFor={params.routeFor}
/>
);
}

function PairingForm({
routePairingUrl,
routeFor,
}: {
readonly routePairingUrl: string;
readonly routeFor: EnvironmentId | undefined;
}) {
const { connectionPairingUrl, onChangeConnectionPairingUrl, onConnectPress } =
useRemoteConnections();
const navigation = useNavigation();
const routeName = useRoute().name;
const insets = useSafeAreaInsets();
const [hostInput, setHostInput] = useState("");
const [codeInput, setCodeInput] = useState("");
const [initialPairing] = useState(() => parsePairingUrl(routePairingUrl || connectionPairingUrl));
const [hostInput, setHostInput] = useState(initialPairing.host);
const [codeInput, setCodeInput] = useState(initialPairing.code);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// The user's own edit or submit dismisses the dev auto-connect report.
const [ownAttempt, setOwnAttempt] = useState(false);
const autoConnect = autoConnectReport(
useAtomValue(devAutoConnectAttemptAtom),
routePairingUrl,
ownAttempt,
);
const connecting = isSubmitting || autoConnect.connecting;
const pairingConnectionError = submitError ?? autoConnect.error;
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
const screenFocused = useIsFocused();
const [scannerLocked, setScannerLocked] = useState(false);
// A good scan parks the camera so it can't re-read the code over later edits.
const [scanComplete, setScanComplete] = useState(false);
const attemptedAutoConnectRef = useRef<string | null>(null);

const headerIconColor = useUniwindTheme()["--color-icon"];

const connectDisabled = isSubmitting || hostInput.trim().length === 0;

useEffect(() => {
const { host, code } = parsePairingUrl(connectionPairingUrl);
// oxlint-disable-next-line react/set-state-in-effect -- A new pairing URL reseeds the editable host and code fields.
setHostInput(host);
setCodeInput(code);
}, [connectionPairingUrl]);

useEffect(() => {
if (routePairingUrl.length === 0) {
return;
}
const connectDisabled = connecting || hostInput.trim().length === 0;

const { host, code } = parsePairingUrl(routePairingUrl);
// oxlint-disable-next-line react/set-state-in-effect -- A pairing URL from the route reseeds the editable host and code fields.
setHostInput(host);
setCodeInput(code);
}, [routePairingUrl]);

useEffect(() => {
if (pairingConnectionError) {
// oxlint-disable-next-line react/set-state-in-effect -- A pairing failure reported by the connection runtime ends the submit.
setIsSubmitting(false);
}
}, [pairingConnectionError]);
const clearError = () => {
setSubmitError(null);
setOwnAttempt(true);
};

const handleHostChange = (value: string) => {
setHostInput(value);
setPendingConnectionError(null);
clearError();
};

const handleCodeChange = (value: string) => {
setCodeInput(value);
setPendingConnectionError(null);
clearError();
};

useEffect(() => () => setPendingConnectionError(null), []);

const handleScanPress = async () => {
const permission = await requestCameraPermission();
if (permission.granted) {
Expand Down Expand Up @@ -156,17 +151,21 @@ export function ConnectionsNewRouteScreen({
}
};

const connectAndClose = async (pairingUrl: string) => {
const handleSubmit = async () => {
const pairingUrl = buildPairingUrl(hostInput, codeInput);
setOwnAttempt(true);
setSubmitError(null);
setIsSubmitting(true);
onChangeConnectionPairingUrl(pairingUrl);
const result = await onConnectPress(pairingUrl, params.routeFor);
const outcome = await onConnectPress(pairingUrl, routeFor);
setIsSubmitting(false);
setSubmitError(outcome.error);
void Haptics.notificationAsync(
AsyncResult.isSuccess(result)
outcome.ok
? Haptics.NotificationFeedbackType.Success
: Haptics.NotificationFeedbackType.Error,
);
if (AsyncResult.isSuccess(result)) {
if (outcome.ok) {
// Replacing with Home would nest it inside any sheet below; return to the opener.
if (!navigation.canGoBack()) {
navigation.dispatch(StackActions.replace("Home"));
Expand All @@ -177,27 +176,14 @@ export function ConnectionsNewRouteScreen({
}
};

const handleSubmit = async () => {
await connectAndClose(buildPairingUrl(hostInput, codeInput));
};

useEffect(() => {
if (!shouldAutoConnect || attemptedAutoConnectRef.current === routePairingUrl) {
return;
}

attemptedAutoConnectRef.current = routePairingUrl;
void connectAndClose(routePairingUrl);
}, [connectAndClose, routePairingUrl, shouldAutoConnect]);

return (
<SettingsScreen
formSheet={routeName === "ConnectionsNew"}
title="Add environment"
actions={[
{
accessibilityLabel: isSubmitting ? "Connecting" : "Add environment",
loading: isSubmitting,
accessibilityLabel: connecting ? "Connecting" : "Add environment",
loading: connecting,
icon: "checkmark",
tintColor: headerIconColor,
disabled: connectDisabled,
Expand Down
Loading
Loading