Skip to content
Open
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: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=hzxSgY2cH10sDU2r
# automatically.
T3CODE_RELAY_URL=https://relay.t3.codes

# Optional WebRTC ICE configuration. Custom TURN URLs win. Otherwise, setting
# the Cloudflare TURN key ID and API token makes the server mint a short-lived
# credential for every authenticated WebRTC upgrade. Keep real secrets out of
# committed env files.
# T3CODE_WEBRTC_STUN_URLS=stun:stun.cloudflare.com:3478
# T3CODE_WEBRTC_TURN_URLS=turn:turn.example.com:3478,turns:turn.example.com:5349
# T3CODE_WEBRTC_TURN_USERNAME=turn-user
# T3CODE_WEBRTC_TURN_CREDENTIAL=replace-me
# T3CODE_WEBRTC_CLOUDFLARE_TURN_KEY_ID=replace-me
# T3CODE_WEBRTC_CLOUDFLARE_TURN_API_TOKEN=replace-me
# Direct WebRTC connections use inbound UDP ports 60000-61000. Allow this
# range through the server host's firewall.

# Optional: hosted app origin used by the CLI's out-of-band OAuth flow.
# Defaults to https://app.t3.codes; override to test against a staging deployment.
# T3CODE_HOSTED_APP_URL=https://nightly.app.t3.codes
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const clientSettings: ClientSettings = {
fontSmoothing: true,
glassOpacity: 80,
progressiveThreadHistoryEnabled: false,
webRtcUpgradeEnabled: true,
planModeEnabled: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ const config: ExpoConfig = {
},
plugins: [
"expo-asset",
"@config-plugins/react-native-webrtc",
[
"expo-font",
{
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"dependencies": {
"@callstack/liquid-glass": "^0.7.1",
"@clerk/expo": "catalog:",
"@config-plugins/react-native-webrtc": "^15.0.1",
"@effect/atom-react": "catalog:",
"@expo-google-fonts/dm-sans": "^0.4.2",
"@expo/metro-runtime": "~56.0.15",
Expand All @@ -67,6 +68,7 @@
"@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",
"@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",
"@t3tools/shared": "workspace:*",
"@t3tools/websocket-webrtc": "workspace:*",
"@tabler/icons-react-native": "^3.44.0",
"clsx": "^2.1.1",
"diff": "8.0.3",
Expand Down Expand Up @@ -114,6 +116,7 @@
"react-native-screens": "4.25.2",
"react-native-shiki-engine": "^0.3.12",
"react-native-svg": "15.15.4",
"react-native-webrtc": "^124.0.8",
"react-native-webview": "^13.16.1",
"react-native-worklets": "0.8.3",
"shiki": "4.2.0",
Expand Down
159 changes: 157 additions & 2 deletions apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,161 @@ import {
Wakeups,
} from "@t3tools/client-runtime/connection";
import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
import {
makeClientWebRtcPeerFactory,
type PlatformWebRtcPeerConnection,
type WebRtcSessionDescription,
} from "@t3tools/websocket-webrtc/client";
import {
type WebRtcDataChannelPort,
type WebRtcIceServer,
WebRtcClientPlatform,
WebRtcPeerError,
WebRtcUpgradePreference,
} from "@t3tools/websocket-webrtc/peer";
import { AuthStandardClientScopes } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Stream from "effect/Stream";
import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity";
import Constants from "expo-constants";
import * as ExpoCrypto from "expo-crypto";
import * as Network from "expo-network";
import { AppState } from "react-native";
import { RTCPeerConnection, RTCSessionDescription } from "react-native-webrtc";

import { authClientMetadata } from "../lib/authClientMetadata";
import * as Runtime from "../lib/runtime";
import * as MobileStorage from "../persistence/mobile-storage";
import { appAtomRegistry } from "../state/atom-registry";
import { mobilePreferencesAtom } from "../state/preferences";
import { clearThreadOutboxEnvironment } from "../state/thread-outbox";
import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
import { mobileApplicationActiveWakeup } from "./app-state-wakeups";
import { connectionStorageLayer } from "./storage";

type MobileDataChannel = ReturnType<RTCPeerConnection["createDataChannel"]>;

interface MobileDataChannelMessageEvent {
readonly data: string | ArrayBuffer | Blob;
}

interface MobileDataChannelEventTarget {
addEventListener(type: "message", listener: (event: MobileDataChannelMessageEvent) => void): void;
addEventListener(type: "close" | "error" | "open", listener: () => void): void;
removeEventListener(
type: "message",
listener: (event: MobileDataChannelMessageEvent) => void,
): void;
removeEventListener(type: "close" | "error" | "open", listener: () => void): void;
}

function mobileDataChannelPort(channel: MobileDataChannel): WebRtcDataChannelPort {
// react-native-webrtc implements EventTarget here, but its generated declaration omits it.
const eventChannel = channel as MobileDataChannel & MobileDataChannelEventTarget;
channel.binaryType = "arraybuffer";
return {
label: channel.label,
ordered: channel.ordered,
isOpen: () => channel.readyState === "open",
bufferedAmount: () => channel.bufferedAmount,
send: (data) => channel.send(data),
close: () => channel.close(),
onOpen: (listener) => {
eventChannel.addEventListener("open", listener);
return () => eventChannel.removeEventListener("open", listener);
},
onMessage: (listener) => {
const onMessage = (event: MobileDataChannelMessageEvent) => {
if (event.data instanceof ArrayBuffer) {
listener(new Uint8Array(event.data));
return;
}
if (typeof event.data === "string") {
listener(new TextEncoder().encode(event.data));
return;
}
void event.data
.arrayBuffer()
.then((buffer: ArrayBuffer) => listener(new Uint8Array(buffer)));
};
eventChannel.addEventListener("message", onMessage);
return () => eventChannel.removeEventListener("message", onMessage);
},
onClose: (listener) => {
eventChannel.addEventListener("close", listener);
return () => eventChannel.removeEventListener("close", listener);
},
onError: (listener) => {
const onError = () => listener(new Error("Mobile WebRTC DataChannel error."));
eventChannel.addEventListener("error", onError);
return () => eventChannel.removeEventListener("error", onError);
},
};
}

function mobileSessionDescription(
description: RTCSessionDescription,
): WebRtcSessionDescription | null {
if (description.type !== "offer" && description.type !== "answer") {
return null;
}
return { type: description.type, sdp: description.sdp };
}

function createMobileRtcPeerConnection(
iceServers: ReadonlyArray<WebRtcIceServer>,
): PlatformWebRtcPeerConnection {
const peer = new RTCPeerConnection({
iceServers: iceServers.map((server) => ({
urls: [...server.urls],
...(server.username === undefined ? {} : { username: server.username }),
...(server.credential === undefined ? {} : { credential: server.credential }),
})),
});
return {
createDataChannel: (label) =>
mobileDataChannelPort(peer.createDataChannel(label, { ordered: true })),
createOffer: () =>
peer
.createOffer()
.then((description) => mobileSessionDescription(new RTCSessionDescription(description))),
setLocalDescription: (description) =>
peer.setLocalDescription(new RTCSessionDescription(description)),
localDescription: () =>
peer.localDescription === null ? null : mobileSessionDescription(peer.localDescription),
setRemoteDescription: (description) =>
peer.setRemoteDescription(new RTCSessionDescription(description)),
iceGatheringState: () => peer.iceGatheringState,
onIceGatheringStateChange: (listener) => {
peer.onicegatheringstatechange = listener;
return () => {
peer.onicegatheringstatechange = null;
};
},
onConnectionStateChange: (listener) => {
const onStateChange = () => listener(peer.connectionState);
peer.onconnectionstatechange = onStateChange;
return () => {
peer.onconnectionstatechange = null;
};
},
close: () => peer.close(),
};
}

const mobileWebRtcClientPlatform = makeClientWebRtcPeerFactory({
createPeerConnection: createMobileRtcPeerConnection,
randomBytes: (size) =>
Effect.try({
try: () => ExpoCrypto.getRandomBytes(size),
catch: (cause) => new WebRtcPeerError({ stage: "create", cause }),
}),
});

function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" {
if (state.isConnected === false) {
return "offline";
Expand Down Expand Up @@ -107,8 +242,19 @@ const wakeupsLayer = Wakeups.layer({
(subscription) => Effect.sync(() => subscription.remove()),
).pipe(Effect.asVoid),
),
managedRelayAccountChanges(appAtomRegistry).pipe(
Stream.map(() => "credentials-changed" as const),
Stream.merge(
managedRelayAccountChanges(appAtomRegistry).pipe(
Stream.map(() => "credentials-changed" as const),
),
AtomRegistry.toStream(appAtomRegistry, mobilePreferencesAtom).pipe(
Stream.map(
(preferences) =>
!AsyncResult.isSuccess(preferences) || preferences.value.webRtcUpgradeEnabled !== false,
),
Stream.changes,
Stream.drop(1),
Stream.map(() => "webrtc-preference-changed" as const),
),
),
),
});
Expand Down Expand Up @@ -192,6 +338,15 @@ const capabilitiesLayer = Layer.effectContext(
disconnect: () => Effect.void,
}),
),
Context.add(WebRtcClientPlatform, mobileWebRtcClientPlatform),
Context.add(WebRtcUpgradePreference, {
isEnabled: Effect.sync(() => {
const preferences = appAtomRegistry.get(mobilePreferencesAtom);
return (
!AsyncResult.isSuccess(preferences) || preferences.value.webRtcUpgradeEnabled !== false
);
}),
}),
);
}),
);
Expand Down
24 changes: 24 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ function LocalSettingsRouteScreen() {

<GeneralSettingsSection />

<ExperimentalSettingsSection />

<SettingsSection title="Appearance">
<SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
</SettingsSection>
Expand Down Expand Up @@ -513,6 +515,8 @@ function ConfiguredSettingsRouteScreen() {

<GeneralSettingsSection />

<ExperimentalSettingsSection />

<SettingsSection title="Appearance">
<SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
</SettingsSection>
Expand Down Expand Up @@ -548,6 +552,26 @@ function GeneralSettingsSection() {
);
}

function ExperimentalSettingsSection() {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const webRtcUpgradeEnabled =
!AsyncResult.isSuccess(preferencesResult) ||
preferencesResult.value.webRtcUpgradeEnabled !== false;

return (
<SettingsSection title="Experimental">
<SettingsSwitchRow
icon="antenna.radiowaves.left.and.right"
label="WebRTC transport"
subtitle="Attempts to upgrade RPC from WebSocket and reconnects environments when changed."
value={webRtcUpgradeEnabled}
onValueChange={(value) => savePreferences({ webRtcUpgradeEnabled: value })}
/>
</SettingsSection>
);
}

/**
* Device-local legacy toggles. Mobile has no client-settings sync, so this is
* the counterpart of web's Settings → General → Legacy features backed by
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface Preferences {
readonly legacyThreadListEnabled?: boolean;
/** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */
readonly planModeEnabled?: boolean;
readonly webRtcUpgradeEnabled?: boolean;
}

export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
Expand Down Expand Up @@ -102,6 +103,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
progressiveThreadHistoryEnabled?: boolean;
legacyThreadListEnabled?: boolean;
planModeEnabled?: boolean;
webRtcUpgradeEnabled?: boolean;
} = {};

if (typeof parsed.liveActivitiesEnabled === "boolean") {
Expand Down Expand Up @@ -175,6 +177,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.planModeEnabled === "boolean") {
preferences.planModeEnabled = parsed.planModeEnabled;
}
if (typeof parsed.webRtcUpgradeEnabled === "boolean") {
preferences.webRtcUpgradeEnabled = parsed.webRtcUpgradeEnabled;
}
return preferences;
}

Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@t3tools/shared": "workspace:*",
"@t3tools/tailscale": "workspace:*",
"@t3tools/web": "workspace:*",
"@t3tools/websocket-webrtc": "workspace:*",
"@types/bun": "1.3.14",
"@types/node": "catalog:",
"effect-acp": "workspace:*",
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts";
import { fixPath } from "./os-jank.ts";
import { websocketRpcRouteLayer } from "./ws.ts";
import * as WebRtcIceServerProvider from "./webrtc/WebRtcIceServerProvider.ts";
import * as ExternalLauncher from "./process/externalLauncher.ts";
import { pullRequestHttpApiLayer } from "./pullRequest/http.ts";
import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts";
Expand Down Expand Up @@ -472,7 +473,7 @@ export const makeRoutesLayer = Layer.mergeAll(
otlpTracesProxyRouteLayer,
assetRouteLayer,
staticAndDevRouteLayer,
websocketRpcRouteLayer,
websocketRpcRouteLayer.pipe(Layer.provide(WebRtcIceServerProvider.layer)),
),
McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)),
).pipe(
Expand Down
Loading
Loading