Skip to content

Commit f57c163

Browse files
Add shared remote runtime for mobile and web
- Move remote HTTP calls into `@t3tools/client-runtime` - Let mobile parse hosted pairing links and use shared runtime - Keep web and mobile remote connection flows aligned
1 parent 72016b1 commit f57c163

20 files changed

Lines changed: 722 additions & 960 deletions

apps/mobile/app.config.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ function resolveAppVariant(value: string | undefined): AppVariant {
4949
}
5050

5151
const variant = VARIANT_CONFIG[APP_VARIANT];
52-
const allowsCleartextTraffic = APP_VARIANT === "development";
5352

5453
const config: ExpoConfig = {
5554
name: variant.appName,
@@ -78,13 +77,11 @@ const config: ExpoConfig = {
7877
supportsTablet: true,
7978
bundleIdentifier: variant.iosBundleIdentifier,
8079
infoPlist: {
81-
...(allowsCleartextTraffic
82-
? {
83-
NSAppTransportSecurity: {
84-
NSAllowsArbitraryLoads: true,
85-
},
86-
}
87-
: {}),
80+
NSAppTransportSecurity: {
81+
NSAllowsArbitraryLoads: true,
82+
},
83+
NSLocalNetworkUsageDescription:
84+
"Allow T3 Code to connect to T3 Code servers on your local network or tailnet.",
8885
ITSAppUsesNonExemptEncryption: false,
8986
},
9087
},
@@ -133,7 +130,7 @@ const config: ExpoConfig = {
133130
],
134131
"expo-secure-store",
135132
"expo-router",
136-
...(allowsCleartextTraffic ? ["./plugins/withAndroidCleartextTraffic.cjs"] : []),
133+
"./plugins/withAndroidCleartextTraffic.cjs",
137134
],
138135
extra: {
139136
appVariant: APP_VARIANT,

apps/mobile/src/app/index.tsx

Lines changed: 10 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,12 @@
11
import { Stack, useRouter } from "expo-router";
2-
import { type ComponentProps, useState } from "react";
3-
import { Pressable, Text as RNText, View, useColorScheme } from "react-native";
4-
import { SymbolView } from "expo-symbols";
2+
import { useState } from "react";
3+
import { Text as RNText, View, useColorScheme } from "react-native";
54
import { useThemeColor } from "../lib/useThemeColor";
65

76
import { buildThreadRoutePath } from "../lib/routes";
87
import { useRemoteCatalog } from "../state/use-remote-catalog";
98
import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry";
109
import { HomeScreen } from "../features/home/HomeScreen";
11-
import type { RemoteCatalogState } from "../state/use-remote-catalog";
12-
13-
function resolveHeaderStatus(state: RemoteCatalogState): {
14-
readonly icon: ComponentProps<typeof SymbolView>["name"];
15-
readonly color: string;
16-
readonly label: string;
17-
} {
18-
if (state.isLoadingSavedConnections) {
19-
return { icon: "hourglass", color: "#737373", label: "Loading environments" };
20-
}
21-
if (state.connectionError) {
22-
return { icon: "exclamationmark.triangle.fill", color: "#ef4444", label: "Environment error" };
23-
}
24-
if (state.connectionState === "ready") {
25-
return { icon: "checkmark.circle.fill", color: "#22c55e", label: "Environment online" };
26-
}
27-
if (state.connectionState === "connecting" || state.connectionState === "reconnecting") {
28-
return {
29-
icon: "arrow.triangle.2.circlepath",
30-
color: "#f59e0b",
31-
label: "Environment connecting",
32-
};
33-
}
34-
return { icon: "wifi.slash", color: "#ef4444", label: "Environment offline" };
35-
}
3610

3711
/* ─── Route screen ───────────────────────────────────────────────────── */
3812

@@ -44,7 +18,6 @@ export default function HomeRouteScreen() {
4418

4519
const isDark = useColorScheme() === "dark";
4620
const iconColor = String(useThemeColor("--color-icon"));
47-
const status = resolveHeaderStatus(catalogState);
4821

4922
return (
5023
<>
@@ -105,15 +78,16 @@ export default function HomeRouteScreen() {
10578
</Stack.Toolbar>
10679

10780
<Stack.Toolbar placement="right">
108-
<Stack.Toolbar.View hidesSharedBackground>
109-
<Pressable
110-
accessibilityLabel={status.label}
111-
className="h-11 w-11 items-center justify-center rounded-full bg-card active:opacity-70"
81+
<Stack.Toolbar.Menu icon="ellipsis.circle" separateBackground>
82+
<Stack.Toolbar.Label>More</Stack.Toolbar.Label>
83+
<Stack.Toolbar.MenuAction
84+
icon="desktopcomputer"
11285
onPress={() => router.push("/connections")}
86+
subtitle="Manage connected hosts"
11387
>
114-
<SymbolView name={status.icon} size={21} tintColor={status.color} type="monochrome" />
115-
</Pressable>
116-
</Stack.Toolbar.View>
88+
<Stack.Toolbar.Label>Environments</Stack.Toolbar.Label>
89+
</Stack.Toolbar.MenuAction>
90+
</Stack.Toolbar.Menu>
11791
</Stack.Toolbar>
11892

11993
{/* Bottom toolbar: search + compose, visually split like iMessage */}

apps/mobile/src/features/connection/pairing.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vitest";
22

3-
import { extractPairingUrlFromQrPayload } from "./pairing";
3+
import { extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing";
44

55
describe("extractPairingUrlFromQrPayload", () => {
66
it("trims raw pairing urls from qr payloads", () => {
@@ -23,3 +23,16 @@ describe("extractPairingUrlFromQrPayload", () => {
2323
);
2424
});
2525
});
26+
27+
describe("parsePairingUrl", () => {
28+
it("reads hosted pairing links into backend host fields", () => {
29+
expect(
30+
parsePairingUrl(
31+
"https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%2F#token=pairing-token",
32+
),
33+
).toEqual({
34+
host: "https://desktop.tailnet.ts.net",
35+
code: "pairing-token",
36+
});
37+
});
38+
});

apps/mobile/src/features/connection/pairing.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { readHostedPairingRequest } from "@t3tools/shared/remote";
2+
13
const MOBILE_PAIRING_URL_PARAM = "pairingUrl";
24

35
export function buildPairingUrl(host: string, code: string): string {
@@ -21,6 +23,14 @@ export function parsePairingUrl(url: string): { host: string; code: string } {
2123

2224
try {
2325
const parsed = new URL(trimmed);
26+
const hostedPairingRequest = readHostedPairingRequest(parsed);
27+
if (hostedPairingRequest) {
28+
return {
29+
host: hostedPairingRequest.host.replace(/\/$/, ""),
30+
code: hostedPairingRequest.token,
31+
};
32+
}
33+
2434
const hashParams = new URLSearchParams(parsed.hash.slice(1));
2535
const hashToken = hashParams.get("token");
2636
const queryToken = parsed.searchParams.get("token");

apps/mobile/src/lib/connection.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { EnvironmentId } from "@t3tools/contracts";
22
import {
33
bootstrapRemoteBearerSession,
44
fetchRemoteEnvironmentDescriptor,
5-
resolveRemotePairingTarget,
6-
} from "@t3tools/shared/remote";
5+
} from "@t3tools/client-runtime";
6+
import { resolveRemotePairingTarget } from "@t3tools/shared/remote";
7+
import { mobileRemoteHttpRuntime } from "./runtime";
78

89
export interface RemoteConnectionInput {
910
readonly pairingUrl: string;
@@ -33,14 +34,18 @@ export async function bootstrapRemoteConnection(
3334
pairingUrl: input.pairingUrl,
3435
});
3536

36-
const descriptor = await fetchRemoteEnvironmentDescriptor({
37-
httpBaseUrl: target.httpBaseUrl,
38-
});
37+
const descriptor = await mobileRemoteHttpRuntime.runPromise(
38+
fetchRemoteEnvironmentDescriptor({
39+
httpBaseUrl: target.httpBaseUrl,
40+
}),
41+
);
3942

40-
const bootstrap = await bootstrapRemoteBearerSession({
41-
httpBaseUrl: target.httpBaseUrl,
42-
credential: target.credential,
43-
});
43+
const bootstrap = await mobileRemoteHttpRuntime.runPromise(
44+
bootstrapRemoteBearerSession({
45+
httpBaseUrl: target.httpBaseUrl,
46+
credential: target.credential,
47+
}),
48+
);
4449

4550
return {
4651
environmentId: descriptor.environmentId,

apps/mobile/src/lib/runtime.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { fetch as expoFetch } from "expo/fetch";
2+
import * as ManagedRuntime from "effect/ManagedRuntime";
3+
4+
import { remoteHttpClientLayer } from "@t3tools/client-runtime";
5+
6+
export const mobileRemoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(expoFetch));

apps/mobile/src/state/use-remote-environment-registry.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import {
99
createWsRpcClient,
1010
EnvironmentConnectionState,
1111
WsTransport,
12+
resolveRemoteWebSocketConnectionUrl,
1213
} from "@t3tools/client-runtime";
1314
import type { EnvironmentId } from "@t3tools/contracts";
14-
import { resolveRemoteWebSocketConnectionUrl } from "@t3tools/shared/remote";
1515
import * as Arr from "effect/Array";
1616
import * as Order from "effect/Order";
1717
import * as Option from "effect/Option";
@@ -28,6 +28,7 @@ import {
2828
saveConnection,
2929
} from "../lib/storage";
3030
import { appAtomRegistry } from "./atom-registry";
31+
import { mobileRemoteHttpRuntime } from "../lib/runtime";
3132
import {
3233
drainEnvironmentSessions,
3334
notifyEnvironmentConnectionListeners,
@@ -213,11 +214,13 @@ export async function connectSavedEnvironment(
213214

214215
const transport = new WsTransport(
215216
() =>
216-
resolveRemoteWebSocketConnectionUrl({
217-
wsBaseUrl: connection.wsBaseUrl,
218-
httpBaseUrl: connection.httpBaseUrl,
219-
bearerToken: connection.bearerToken,
220-
}),
217+
mobileRemoteHttpRuntime.runPromise(
218+
resolveRemoteWebSocketConnectionUrl({
219+
wsBaseUrl: connection.wsBaseUrl,
220+
httpBaseUrl: connection.httpBaseUrl,
221+
bearerToken: connection.bearerToken,
222+
}),
223+
),
221224
{
222225
onAttempt: () => {
223226
environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (previous) => {

0 commit comments

Comments
 (0)