authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-23 15:13:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-23 15:13:40-07:00
log68fb7f4b850991ba0ca5bd0d7c4aa13d4e5d6bb7
treedc256aa25fd89f0d29253d9f33b2c36b74f23bfc
parent11e91f126405883c0194da5b1e3ad39d81c48966
signature Signed by PGP key B5690EEEBB952194

feat(mobile): manage environment and provider updates (#13302)


13 files changed, 723 insertions(+), 80 deletions(-)

.github/workflows/mobile-eas-preview.yml+13-1
...@@ -78,7 +78,19 @@ jobs:...@@ -78,7 +78,19 @@ jobs:
78 working-directory: apps/mobile78 working-directory: apps/mobile
79 env:79 env:
80 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}80 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
81 run: eas env:pull preview --non-interactive81 run: |
82 eas env:pull preview --non-interactive
83 # EAS Update disables dotenv loading. Keep the downloaded native config
84 # in its process environment so Android build and OTA fingerprints match.
85 node --input-type=module <<'NODE'
86 import { appendFileSync, readFileSync } from "node:fs";
87 import { parseEnv } from "node:util";
88 const env = parseEnv(readFileSync(".env.local", "utf8"));
89 const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE;
90 if (googleServicesFile) {
91 appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`);
92 }
93 NODE
8294
83 - name: Deploy with fingerprint check95 - name: Deploy with fingerprint check
84 if: steps.expo-token.outputs.present == 'true'96 if: steps.expo-token.outputs.present == 'true'
apps/mobile/src/Stack.tsx+6
...@@ -71,6 +71,7 @@ import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppea...@@ -71,6 +71,7 @@ import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppea
71import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";71import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
72import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen";72import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen";
73import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";73import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
74import { SettingsEnvironmentDetailRouteScreen } from "./features/settings/SettingsEnvironmentDetailRouteScreen";
74import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";75import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
75import {76import {
76 SettingsEnvironmentAgentBehaviorRouteScreen,77 SettingsEnvironmentAgentBehaviorRouteScreen,
...@@ -193,6 +194,11 @@ const SettingsContentStack = createNativeStackNavigator({...@@ -193,6 +194,11 @@ const SettingsContentStack = createNativeStackNavigator({
193 title: "Environments",194 title: "Environments",
194 },195 },
195 }),196 }),
197 SettingsEnvironmentDetail: createNativeStackScreen({
198 screen: SettingsEnvironmentDetailRouteScreen,
199 linking: "environments/:environmentId",
200 options: { title: "Environment" },
201 }),
196 SettingsEnvironmentNewThreads: createNativeStackScreen({202 SettingsEnvironmentNewThreads: createNativeStackScreen({
197 screen: SettingsEnvironmentNewThreadsRouteScreen,203 screen: SettingsEnvironmentNewThreadsRouteScreen,
198 linking: "new-threads",204 linking: "new-threads",
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+29-20
...@@ -35,6 +35,7 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect...@@ -35,6 +35,7 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect
3535
36interface CloudEnvironmentRowsProps {36interface CloudEnvironmentRowsProps {
37 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;37 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
38 readonly onOpenEnvironment?: (environmentId: EnvironmentId) => void;
38 readonly onSetEnvironmentEnabled: (environmentId: EnvironmentId, enabled: boolean) => void;39 readonly onSetEnvironmentEnabled: (environmentId: EnvironmentId, enabled: boolean) => void;
39 /** Long-press on a saved row. The callback owns the confirm. */40 /** Long-press on a saved row. The callback owns the confirm. */
40 readonly onRemoveEnvironment: (environmentId: EnvironmentId) => void;41 readonly onRemoveEnvironment: (environmentId: EnvironmentId) => void;
...@@ -124,26 +125,30 @@ function CloudEnvironmentRowsContent(...@@ -124,26 +125,30 @@ function CloudEnvironmentRowsContent(
124 ) : null}125 ) : null}
125126
126 {hasCloudRows ? (127 {hasCloudRows ? (
127 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">128 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-grouped-card">
128 {props.connectedCloudEnvironments.map((environment, index) => (129 {props.connectedCloudEnvironments.map((environment) => (
129 <ConnectedCloudEnvironmentRow130 <ConnectedCloudEnvironmentRow
130 key={environment.environmentId}131 key={environment.environmentId}
131 environment={environment}132 environment={environment}
132 descriptor={discoveredDescriptors.get(environment.environmentId)}133 descriptor={discoveredDescriptors.get(environment.environmentId)}
133 borderTop={index !== 0}
134 onSetEnabled={(enabled) =>134 onSetEnabled={(enabled) =>
135 props.onSetEnvironmentEnabled(environment.environmentId, enabled)135 props.onSetEnvironmentEnabled(environment.environmentId, enabled)
136 }136 }
137 onRemove={() => props.onRemoveEnvironment(environment.environmentId)}137 onRemove={() => props.onRemoveEnvironment(environment.environmentId)}
138 onOpen={
139 props.onOpenEnvironment
140 ? () => props.onOpenEnvironment?.(environment.environmentId)
141 : undefined
142 }
138 errorExpanded={expandedErrorId === environment.environmentId}143 errorExpanded={expandedErrorId === environment.environmentId}
139 onToggleError={() => handleToggleCloudError(environment.environmentId)}144 onToggleError={() => handleToggleCloudError(environment.environmentId)}
140 />145 />
141 ))}146 ))}
142 {availableCloudEnvironments.map((environment, index) => (147 {availableCloudEnvironments.map((environment) => (
143 <CloudEnvironmentRow148 <CloudEnvironmentRow
144 key={environment.environment.environmentId}149 key={environment.environment.environmentId}
145 environment={environment}150 environment={environment}
146 borderTop={props.connectedCloudEnvironments.length > 0 || index !== 0}151 showChevron={props.onOpenEnvironment !== undefined}
147 onConnect={() => handleConnectCloudEnvironment(environment)}152 onConnect={() => handleConnectCloudEnvironment(environment)}
148 errorExpanded={expandedErrorId === environment.environment.environmentId}153 errorExpanded={expandedErrorId === environment.environment.environmentId}
149 onToggleError={() => handleToggleCloudError(environment.environment.environmentId)}154 onToggleError={() => handleToggleCloudError(environment.environment.environmentId)}
...@@ -151,14 +156,14 @@ function CloudEnvironmentRowsContent(...@@ -151,14 +156,14 @@ function CloudEnvironmentRowsContent(
151 ))}156 ))}
152 </View>157 </View>
153 ) : controller.relayDiscovery.isRefreshing ? (158 ) : controller.relayDiscovery.isRefreshing ? (
154 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6">159 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-grouped-card p-6">
155 <ActivityIndicator colorClassName={"accent-icon"} />160 <ActivityIndicator colorClassName={"accent-icon"} />
156 <Text className="text-center text-sm leading-normal text-foreground-muted">161 <Text className="text-center text-sm leading-normal text-foreground-muted">
157 Loading linked cloud environments.162 Loading linked cloud environments.
158 </Text>163 </Text>
159 </View>164 </View>
160 ) : controller.relayDiscovery.error ? null : (165 ) : controller.relayDiscovery.error ? null : (
161 <View collapsable={false} className="rounded-[24px] bg-card p-5">166 <View collapsable={false} className="rounded-[24px] bg-grouped-card p-5">
162 <Text className="text-sm leading-normal text-foreground-muted">167 <Text className="text-sm leading-normal text-foreground-muted">
163 No additional linked cloud environments.168 No additional linked cloud environments.
164 </Text>169 </Text>
...@@ -170,7 +175,7 @@ function CloudEnvironmentRowsContent(...@@ -170,7 +175,7 @@ function CloudEnvironmentRowsContent(
170 {discoveryAvailable &&175 {discoveryAvailable &&
171 controller.relayDiscovery.error &&176 controller.relayDiscovery.error &&
172 !controller.relayDiscovery.isRefreshing ? (177 !controller.relayDiscovery.isRefreshing ? (
173 <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5">178 <View collapsable={false} className="gap-3 rounded-[24px] bg-grouped-card p-5">
174 <Text className="text-base font-t3-bold text-foreground">179 <Text className="text-base font-t3-bold text-foreground">
175 Could not load T3 Connect environments180 Could not load T3 Connect environments
176 </Text>181 </Text>
...@@ -202,10 +207,10 @@ function ConnectedCloudEnvironmentRow(props: {...@@ -202,10 +207,10 @@ function ConnectedCloudEnvironmentRow(props: {
202 readonly environment: ConnectedEnvironmentSummary;207 readonly environment: ConnectedEnvironmentSummary;
203 /** Discovery's view of the server, for the glyph before the first connection. */208 /** Discovery's view of the server, for the glyph before the first connection. */
204 readonly descriptor: ExecutionEnvironmentDescriptor | undefined;209 readonly descriptor: ExecutionEnvironmentDescriptor | undefined;
205 readonly borderTop: boolean;
206 readonly errorExpanded: boolean;210 readonly errorExpanded: boolean;
207 readonly onSetEnabled: (enabled: boolean) => void;211 readonly onSetEnabled: (enabled: boolean) => void;
208 readonly onRemove: () => void;212 readonly onRemove: () => void;
213 readonly onOpen?: (() => void) | undefined;
209 readonly onToggleError: () => void;214 readonly onToggleError: () => void;
210}) {215}) {
211 const serverConfig = useAtomValue(216 const serverConfig = useAtomValue(
...@@ -222,10 +227,13 @@ function ConnectedCloudEnvironmentRow(props: {...@@ -222,10 +227,13 @@ function ConnectedCloudEnvironmentRow(props: {
222 return (227 return (
223 <Pressable228 <Pressable
224 accessibilityHint="Long press to remove from this device"229 accessibilityHint="Long press to remove from this device"
230 accessibilityRole={props.onOpen ? "button" : undefined}
231 accessibilityLabel={props.onOpen ? `Manage ${props.environment.environmentLabel}` : undefined}
232 onPress={props.onOpen}
225 onLongPress={props.onRemove}233 onLongPress={props.onRemove}
226 >234 >
227 <CloudEnvironmentRowShell235 <CloudEnvironmentRowShell
228 borderTop={props.borderTop}236 opensDetails={props.onOpen !== undefined}
229 connectionError={enabled || unsupported ? props.environment.connectionError : null}237 connectionError={enabled || unsupported ? props.environment.connectionError : null}
230 connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null}238 connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null}
231 connectionState={enabled || unsupported ? props.environment.connectionState : "available"}239 connectionState={enabled || unsupported ? props.environment.connectionState : "available"}
...@@ -246,7 +254,7 @@ function ConnectedCloudEnvironmentRow(props: {...@@ -246,7 +254,7 @@ function ConnectedCloudEnvironmentRow(props: {
246254
247function CloudEnvironmentRow(props: {255function CloudEnvironmentRow(props: {
248 readonly environment: RelayEnvironmentView;256 readonly environment: RelayEnvironmentView;
249 readonly borderTop: boolean;257 readonly showChevron: boolean;
250 readonly errorExpanded: boolean;258 readonly errorExpanded: boolean;
251 readonly onConnect: () => void;259 readonly onConnect: () => void;
252 readonly onToggleError: () => void;260 readonly onToggleError: () => void;
...@@ -260,7 +268,7 @@ function CloudEnvironmentRow(props: {...@@ -260,7 +268,7 @@ function CloudEnvironmentRow(props: {
260268
261 return (269 return (
262 <CloudEnvironmentRowShell270 <CloudEnvironmentRowShell
263 borderTop={props.borderTop}271 showChevron={props.showChevron}
264 connectionError={presentation.connectionError}272 connectionError={presentation.connectionError}
265 connectionErrorTraceId={presentation.connectionErrorTraceId}273 connectionErrorTraceId={presentation.connectionErrorTraceId}
266 connectionState={presentation.connectionState}274 connectionState={presentation.connectionState}
...@@ -285,7 +293,8 @@ function CloudEnvironmentRow(props: {...@@ -285,7 +293,8 @@ function CloudEnvironmentRow(props: {
285}293}
286294
287function CloudEnvironmentRowShell(props: {295function CloudEnvironmentRowShell(props: {
288 readonly borderTop: boolean;296 readonly showChevron?: boolean;
297 readonly opensDetails?: boolean;
289 readonly connectionError: string | null;298 readonly connectionError: string | null;
290 readonly connectionErrorTraceId: string | null;299 readonly connectionErrorTraceId: string | null;
291 readonly connectionState: EnvironmentConnectionPhase;300 readonly connectionState: EnvironmentConnectionPhase;
...@@ -340,13 +349,7 @@ function CloudEnvironmentRowShell(props: {...@@ -340,13 +349,7 @@ function CloudEnvironmentRowShell(props: {
340 [measuredErrorText, props.connectionError],349 [measuredErrorText, props.connectionError],
341 );350 );
342 return (351 return (
343 <View352 <View collapsable={false} className="flex-row items-center gap-3 bg-grouped-card px-4 py-3.5">
344 collapsable={false}
345 className={cn(
346 "flex-row items-center gap-3 bg-card px-4 py-3.5",
347 props.borderTop && "border-t border-border",
348 )}
349 >
350 <View className="min-w-0 flex-1 gap-0.5">353 <View className="min-w-0 flex-1 gap-0.5">
351 <View className="min-w-0 flex-row items-center gap-2">354 <View className="min-w-0 flex-row items-center gap-2">
352 <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} />355 <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} />
...@@ -409,10 +412,16 @@ function CloudEnvironmentRowShell(props: {...@@ -409,10 +412,16 @@ function CloudEnvironmentRowShell(props: {
409 </StatusContainer>412 </StatusContainer>
410 </View>413 </View>
411 <ThemedSwitch414 <ThemedSwitch
415 style={{ alignSelf: "center" }}
412 disabled={props.disabled}416 disabled={props.disabled}
413 onValueChange={props.onValueChange}417 onValueChange={props.onValueChange}
414 value={props.value}418 value={props.value}
415 />419 />
420 {props.opensDetails || props.showChevron ? (
421 <View style={{ opacity: props.opensDetails ? 1 : 0.4 }}>
422 <SymbolView name="chevron.right" size={12} tintColorClassName="accent-icon-subtle" />
423 </View>
424 ) : null}
416 </View>425 </View>
417 );426 );
418}427}
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+18-11
...@@ -35,6 +35,7 @@ function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string...@@ -35,6 +35,7 @@ function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string
35export function ConnectionEnvironmentRow(props: {35export function ConnectionEnvironmentRow(props: {
36 readonly environment: ConnectedEnvironmentSummary;36 readonly environment: ConnectedEnvironmentSummary;
37 readonly expanded: boolean;37 readonly expanded: boolean;
38 readonly opensDetails?: boolean;
38 readonly onToggle: () => void;39 readonly onToggle: () => void;
39 readonly onReconnect: (environmentId: EnvironmentId) => void;40 readonly onReconnect: (environmentId: EnvironmentId) => void;
40 readonly onRemove: (environmentId: EnvironmentId) => void;41 readonly onRemove: (environmentId: EnvironmentId) => void;
...@@ -76,19 +77,22 @@ export function ConnectionEnvironmentRow(props: {...@@ -76,19 +77,22 @@ export function ConnectionEnvironmentRow(props: {
76 }, [label, url, props]);77 }, [label, url, props]);
7778
78 return (79 return (
79 <Animated.View layout={LinearTransition.duration(250)} className="bg-card">80 <Animated.View layout={LinearTransition.duration(250)} className="bg-grouped-card">
80 <Pressable81 <Pressable
81 className="flex-row items-center gap-3 px-4 py-3.5 active:opacity-70"82 className="flex-row items-center gap-3 px-4 py-3.5 active:opacity-70"
83 accessibilityRole="button"
84 accessibilityLabel={
85 props.opensDetails ? `Manage ${props.environment.environmentLabel}` : undefined
86 }
82 onPress={props.onToggle}87 onPress={props.onToggle}
83 >88 >
84 <ConnectionStatusDot
85 state={enabled || unsupported ? props.environment.connectionState : "available"}
86 pulse={isRetrying}
87 size={8}
88 />
89
90 <View className="flex-1 gap-0.5">89 <View className="flex-1 gap-0.5">
91 <View className="flex-row items-center gap-1.5">90 <View className="flex-row items-center gap-1.5">
91 <ConnectionStatusDot
92 state={enabled || unsupported ? props.environment.connectionState : "available"}
93 pulse={isRetrying}
94 size={8}
95 />
92 <EnvironmentMachineSymbol96 <EnvironmentMachineSymbol
93 kind={resolveEnvironmentMachineKind(serverConfig)}97 kind={resolveEnvironmentMachineKind(serverConfig)}
94 size={14}98 size={14}
...@@ -101,9 +105,11 @@ export function ConnectionEnvironmentRow(props: {...@@ -101,9 +105,11 @@ export function ConnectionEnvironmentRow(props: {
101 {props.environment.environmentLabel}105 {props.environment.environmentLabel}
102 </Text>106 </Text>
103 </View>107 </View>
104 <Text className="text-xs text-foreground-muted" numberOfLines={1}>108 {!props.environment.isRelayManaged && props.environment.displayUrl.trim() ? (
105 {props.environment.displayUrl}109 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
106 </Text>110 {props.environment.displayUrl}
111 </Text>
112 ) : null}
107 {statusLabel ? (113 {statusLabel ? (
108 <Text114 <Text
109 className={cn(115 className={cn(
...@@ -126,12 +132,13 @@ export function ConnectionEnvironmentRow(props: {...@@ -126,12 +132,13 @@ export function ConnectionEnvironmentRow(props: {
126 </View>132 </View>
127133
128 <ThemedSwitch134 <ThemedSwitch
135 style={{ alignSelf: "center" }}
129 disabled={unsupported}136 disabled={unsupported}
130 onValueChange={(next) => props.onSetEnabled(props.environment.environmentId, next)}137 onValueChange={(next) => props.onSetEnabled(props.environment.environmentId, next)}
131 value={enabled}138 value={enabled}
132 />139 />
133 <SymbolView140 <SymbolView
134 name="chevron.down"141 name={props.opensDetails ? "chevron.right" : "chevron.down"}
135 size={12}142 size={12}
136 tintColorClassName="accent-icon-subtle"143 tintColorClassName="accent-icon-subtle"
137 type="monochrome"144 type="monochrome"
apps/mobile/src/features/connection/GitHubRoutingSettings.tsx+3-6
...@@ -43,15 +43,12 @@ export function GitHubRoutingSettings() {...@@ -43,15 +43,12 @@ export function GitHubRoutingSettings() {
43 return (43 return (
44 <View className="mt-5 gap-3">44 <View className="mt-5 gap-3">
45 <SettingsSection title="GitHub routing">45 <SettingsSection title="GitHub routing">
46 {[...catalog.entries.values()].map((entry, index) => {46 {[...catalog.entries.values()].map((entry) => {
47 const environmentId = entry.target.environmentId;47 const environmentId = entry.target.environmentId;
48 const selected = gitHubRoutingPermissionFor(entry, permissions);48 const selected = gitHubRoutingPermissionFor(entry, permissions);
49 const disabled = !catalog.isReady || saving || gitHubRoutingConnectionKey(entry) === null;49 const disabled = !catalog.isReady || saving || gitHubRoutingConnectionKey(entry) === null;
50 return (50 return (
51 <View51 <View key={environmentId}>
52 key={environmentId}
53 className={index === 0 ? undefined : "border-t border-border-subtle"}
54 >
55 <Pressable52 <Pressable
56 accessibilityRole="button"53 accessibilityRole="button"
57 accessibilityLabel={`${entry.target.label} GitHub routing`}54 accessibilityLabel={`${entry.target.label} GitHub routing`}
...@@ -83,7 +80,7 @@ export function GitHubRoutingSettings() {...@@ -83,7 +80,7 @@ export function GitHubRoutingSettings() {
83 accessibilityRole="radio"80 accessibilityRole="radio"
84 accessibilityState={{ checked: selected === option.value, disabled }}81 accessibilityState={{ checked: selected === option.value, disabled }}
85 disabled={disabled}82 disabled={disabled}
86 className="flex-row items-center gap-4 border-t border-border-subtle p-4 disabled:opacity-50"83 className="flex-row items-center gap-4 p-4 disabled:opacity-50"
87 onPress={() => {84 onPress={() => {
88 setSaving(true);85 setSaving(true);
89 void update({ environmentId, permission: option.value }).then((result) => {86 void update({ environmentId, permission: option.value }).then((result) => {
apps/mobile/src/features/connection/LocalEnvironmentList.tsx+11-10
...@@ -4,7 +4,6 @@ import { View } from "react-native";...@@ -4,7 +4,6 @@ import { View } from "react-native";
44
5import { SymbolView } from "../../components/AppSymbol";5import { SymbolView } from "../../components/AppSymbol";
6import { AppText as Text } from "../../components/AppText";6import { AppText as Text } from "../../components/AppText";
7import { cn } from "../../lib/cn";
8import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow";7import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow";
98
10type EnvironmentRowProps = ComponentProps<typeof ConnectionEnvironmentRow>;9type EnvironmentRowProps = ComponentProps<typeof ConnectionEnvironmentRow>;
...@@ -15,14 +14,20 @@ export function LocalEnvironmentList({...@@ -15,14 +14,20 @@ export function LocalEnvironmentList({
15 expandedId,14 expandedId,
16 onToggle,15 onToggle,
17 ...rowActions16 ...rowActions
18}: Pick<EnvironmentRowProps, "onReconnect" | "onRemove" | "onSetEnabled" | "onUpdate"> & {17}: Pick<
18 EnvironmentRowProps,
19 "onReconnect" | "onRemove" | "onSetEnabled" | "onUpdate" | "opensDetails"
20> & {
19 readonly environments: ReadonlyArray<EnvironmentRowProps["environment"]>;21 readonly environments: ReadonlyArray<EnvironmentRowProps["environment"]>;
20 readonly expandedId: EnvironmentId | null;22 readonly expandedId: EnvironmentId | null;
21 readonly onToggle: (environmentId: EnvironmentId) => void;23 readonly onToggle: (environmentId: EnvironmentId) => void;
22}) {24}) {
23 if (environments.length === 0) {25 if (environments.length === 0) {
24 return (26 return (
25 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">27 <View
28 collapsable={false}
29 className="items-center gap-3 rounded-[24px] bg-grouped-card px-6 py-8"
30 >
26 <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle">31 <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle">
27 <SymbolView32 <SymbolView
28 name="point.3.connected.trianglepath.dotted"33 name="point.3.connected.trianglepath.dotted"
...@@ -40,13 +45,9 @@ export function LocalEnvironmentList({...@@ -40,13 +45,9 @@ export function LocalEnvironmentList({
40 }45 }
4146
42 return (47 return (
43 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">48 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-grouped-card">
44 {environments.map((environment, index) => (49 {environments.map((environment) => (
45 <View50 <View key={environment.environmentId} collapsable={false}>
46 key={environment.environmentId}
47 collapsable={false}
48 className={cn(index !== 0 && "border-t border-border")}
49 >
50 <ConnectionEnvironmentRow51 <ConnectionEnvironmentRow
51 environment={environment}52 environment={environment}
52 expanded={expandedId === environment.environmentId}53 expanded={expandedId === environment.environmentId}
apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx created+341
...@@ -0,0 +1,341 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { StaticScreenProps } from "@react-navigation/native";
3import type { EnvironmentId, ServerProvider } from "@t3tools/contracts";
4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import { AsyncResult } from "effect/unstable/reactivity";
6import { useEffect, useRef, useState } from "react";
7import { Alert, View } from "react-native";
8import { useSafeAreaInsets } from "react-native-safe-area-context";
9
10import { AppText as Text } from "../../components/AppText";
11import { ProviderIcon } from "../../components/ProviderIcon";
12import { ScreenScrollView } from "../../components/ScreenScrollView";
13import { serverEnvironment } from "../../state/server";
14import { environmentSession } from "../../state/session";
15import { useAtomCommand } from "../../state/use-atom-command";
16import { useRemoteConnections } from "../../state/use-remote-environment-registry";
17import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
18import { SettingsActionRow } from "./components/SettingsActionRow";
19import { SettingsScreen } from "./components/SettingsScreen";
20import { SettingsSection } from "./components/SettingsSection";
21import {
22 canMaintainEnvironment,
23 canUpdateEnvironmentProvider,
24 findEnvironmentUpdate,
25 supportsEnvironmentUpdate,
26} from "./environment-maintenance";
27
28export function SettingsEnvironmentDetailRouteScreen({
29 route,
30}: StaticScreenProps<{
31 readonly environmentId: EnvironmentId;
32}>) {
33 // Key local request state to the host even when navigation reuses this screen.
34 return (
35 <EnvironmentDetail
36 key={route.params.environmentId}
37 environmentId={route.params.environmentId}
38 />
39 );
40}
41
42function EnvironmentDetail({ environmentId }: { readonly environmentId: EnvironmentId }) {
43 const insets = useSafeAreaInsets();
44 const connections = useRemoteConnections();
45 const environment = connections.connectedEnvironments.find(
46 (entry) => entry.environmentId === environmentId,
47 );
48 const config = useAtomValue(serverEnvironment.configValueAtom(environmentId));
49 const session = useAtomValue(environmentSession.sessionStateValueAtom(environmentId));
50 const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId));
51 const updateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));
52 const updateServer = useAtomCommand(serverEnvironment.updateServer);
53 const updateProvider = useAtomCommand(serverEnvironment.updateProvider);
54 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders);
55 const [connectionExpanded, setConnectionExpanded] = useState(false);
56 const [pending, setPending] = useState<string | null>(null);
57 const pendingRef = useRef(false);
58 const [error, setError] = useState<string | null>(null);
59 const [notice, setNotice] = useState<string | null>(null);
60 const [release, setRelease] = useState<{
61 fromVersion: string;
62 targetVersion: string | null;
63 } | null>(null);
64 const checkController = useRef<AbortController | null>(null);
65 useEffect(() => () => checkController.current?.abort(), []);
66
67 const connected = environment?.isEnabled === true && environment.connectionState === "connected";
68 const allowed =
69 !AsyncResult.isFailure(sessionResult) && canMaintainEnvironment(session, connected);
70 const running = updateState.status === "running";
71 const providerBusy =
72 config?.providers.some(
73 (provider) =>
74 provider.updateState?.status === "running" || provider.updateState?.status === "queued",
75 ) ?? false;
76 const disabled = !allowed || pending !== null || running || providerBusy;
77 const version = config?.environment.serverVersion;
78 const checkedRelease = release?.fromVersion === version ? release : null;
79 const capabilities = config?.environment.capabilities;
80
81 async function run(label: string, action: () => Promise<void>) {
82 if (pendingRef.current) return;
83 pendingRef.current = true;
84 setPending(label);
85 setError(null);
86 setNotice(null);
87 try {
88 await action();
89 } catch (cause) {
90 setError(
91 cause instanceof Error ? cause.message : "The action could not be completed. Try again.",
92 );
93 } finally {
94 pendingRef.current = false;
95 setPending(null);
96 }
97 }
98
99 function requestServerUpdate() {
100 const targetVersion = checkedRelease?.targetVersion;
101 if (disabled || !targetVersion || !capabilities || !supportsEnvironmentUpdate(capabilities))
102 return;
103 Alert.alert(
104 `Update ${environment?.environmentLabel ?? "environment"}?`,
105 `Install T3 Code ${targetVersion}. ${capabilities.serverSelfUpdate === "desktop-managed" ? "The desktop app will close and relaunch." : "The server will restart and reconnect."} Running threads may be interrupted.`,
106 [
107 { text: "Cancel", style: "cancel" },
108 {
109 text: "Update",
110 onPress: () =>
111 void run("server", async () => {
112 const result = await updateServer({
113 environmentId,
114 input: {
115 targetVersion,
116 ...(capabilities.serverUpdateThreadContinuation &&
117 config?.settings.continueThreadsAfterServerUpdate
118 ? { continueRunningThreads: true }
119 : {}),
120 },
121 });
122 if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result);
123 setRelease(null);
124 setNotice(`Updated to ${result.value.targetVersion}.`);
125 }),
126 },
127 ],
128 );
129 }
130
131 function requestProviderUpdate(provider: ServerProvider) {
132 if (disabled || !canUpdateEnvironmentProvider(provider)) return;
133 void run(provider.instanceId, async () => {
134 const result = await updateProvider({
135 environmentId,
136 input: {
137 provider: provider.driver,
138 instanceId: provider.instanceId,
139 },
140 });
141 if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result);
142 });
143 }
144
145 return (
146 <SettingsScreen title={environment?.environmentLabel ?? "Environment"}>
147 <ScreenScrollView
148 contentInsetAdjustmentBehavior="automatic"
149 className="flex-1"
150 contentContainerClassName="gap-6 px-5 pt-4"
151 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
152 >
153 {!environment ? (
154 <Text className="text-base text-foreground-muted">
155 This environment is no longer saved on this device.
156 </Text>
157 ) : (
158 <>
159 <SettingsSection title="Connection">
160 <ConnectionEnvironmentRow
161 environment={environment}
162 expanded={connectionExpanded}
163 onToggle={() => setConnectionExpanded((value) => !value)}
164 onReconnect={connections.onReconnectEnvironment}
165 onRemove={connections.onRemoveEnvironmentPress}
166 onSetEnabled={connections.onSetEnvironmentEnabled}
167 onUpdate={connections.onUpdateEnvironment}
168 />
169 </SettingsSection>
170 {!connected ? (
171 <Text className="px-2 text-sm text-foreground-muted">
172 Connect this environment to manage it.
173 </Text>
174 ) : !allowed ? (
175 <Text className="px-2 text-sm text-foreground-muted">
176 {AsyncResult.isFailure(sessionResult)
177 ? "Could not verify your permissions. Reconnect to try again."
178 : session === null
179 ? "Checking permissions…"
180 : "This connection does not have permission to manage the environment."}
181 </Text>
182 ) : null}
183 {error ? (
184 <Text selectable className="px-2 text-sm text-danger-foreground">
185 {error}
186 </Text>
187 ) : null}
188 {notice ? <Text className="px-2 text-sm text-foreground-muted">{notice}</Text> : null}
189 {config ? (
190 <>
191 <SettingsSection title="T3 Code">
192 <View className="gap-1 p-4">
193 <Text className="text-base text-foreground">Version {version}</Text>
194 {running ? (
195 <Text className="text-sm text-foreground-muted">
196 {updateState.stage === "resuming"
197 ? "Restarting and reconnecting…"
198 : "Downloading update…"}
199 </Text>
200 ) : updateState.status === "failed" ? (
201 <Text selectable className="text-sm text-danger-foreground">
202 {updateState.message}
203 </Text>
204 ) : null}
205 {checkedRelease ? (
206 <Text className="text-sm text-foreground-muted">
207 {checkedRelease.targetVersion
208 ? `Version ${checkedRelease.targetVersion} is available.`
209 : "You are up to date."}
210 </Text>
211 ) : null}
212 {!supportsEnvironmentUpdate(config.environment.capabilities) ? (
213 <Text className="text-sm text-foreground-muted">
214 {capabilities?.serverSelfUpdate === "desktop-managed"
215 ? "Update the desktop app on this machine."
216 : "Update and restart T3 Code on this machine."}
217 </Text>
218 ) : null}
219 </View>
220 <SettingsActionRow
221 icon="arrow.clockwise"
222 label="Check for updates"
223 disabled={disabled}
224 loading={pending === "check"}
225 onPress={() => {
226 if (disabled) return;
227 void run("check", async () => {
228 const controller = new AbortController();
229 checkController.current = controller;
230 const timeout = setTimeout(() => controller.abort(), 20_000);
231 try {
232 const targetVersion = await findEnvironmentUpdate(
233 config.environment.serverVersion,
234 controller.signal,
235 );
236 setRelease({
237 fromVersion: config.environment.serverVersion,
238 targetVersion,
239 });
240 } finally {
241 clearTimeout(timeout);
242 checkController.current = null;
243 }
244 });
245 }}
246 />
247 {checkedRelease?.targetVersion &&
248 supportsEnvironmentUpdate(config.environment.capabilities) ? (
249 <SettingsActionRow
250 icon="arrow.up.circle"
251 label={`Update to ${checkedRelease.targetVersion}`}
252 disabled={disabled}
253 loading={pending === "server" || running}
254 onPress={requestServerUpdate}
255 />
256 ) : null}
257 </SettingsSection>
258 <SettingsSection title="Providers">
259 <SettingsActionRow
260 icon="arrow.clockwise"
261 label="Refresh providers"
262 disabled={disabled}
263 loading={pending === "refresh"}
264 onPress={() => {
265 if (disabled) return;
266 void run("refresh", async () => {
267 const result = await refreshProviders({ environmentId, input: {} });
268 if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result);
269 setNotice("Provider status refreshed.");
270 });
271 }}
272 />
273 {config.providers
274 .filter((provider) => provider.enabled)
275 .map((provider) => (
276 <View key={provider.instanceId}>
277 <View className="gap-1 p-4">
278 <View className="flex-row items-center gap-2">
279 <ProviderIcon provider={provider.driver} size={18} />
280 <Text className="min-w-0 flex-1 text-base font-t3-medium text-foreground">
281 {provider.displayName ?? provider.driver}
282 </Text>
283 </View>
284 <Text className="text-sm text-foreground-muted">
285 {provider.installed
286 ? (provider.version ?? "Version unknown")
287 : "Not installed"}
288 {provider.versionAdvisory?.latestVersion
289 ? ` · Latest ${provider.versionAdvisory.latestVersion}`
290 : ""}
291 </Text>
292 {provider.updateState && provider.updateState.status !== "idle" ? (
293 <Text
294 selectable
295 className={
296 provider.updateState.status === "failed"
297 ? "text-sm text-danger-foreground"
298 : "text-sm text-foreground-muted"
299 }
300 >
301 {provider.updateState.message ??
302 `Update ${provider.updateState.status}`}
303 </Text>
304 ) : null}
305 {provider.compatibilityAdvisory?.message ? (
306 <Text selectable className="text-sm text-foreground-muted">
307 {provider.compatibilityAdvisory.message}
308 </Text>
309 ) : null}
310 {provider.unavailableReason || provider.message ? (
311 <Text selectable className="text-sm text-foreground-muted">
312 {provider.unavailableReason ?? provider.message}
313 </Text>
314 ) : null}
315 {provider.versionAdvisory?.status === "behind_latest" &&
316 !provider.versionAdvisory.canUpdate ? (
317 <Text className="text-sm text-foreground-muted">
318 Update this provider on the environment's machine.
319 </Text>
320 ) : null}
321 </View>
322 {canUpdateEnvironmentProvider(provider) ? (
323 <SettingsActionRow
324 icon="arrow.up.circle"
325 label={`Update ${provider.displayName ?? provider.driver}`}
326 disabled={disabled}
327 loading={pending === provider.instanceId}
328 onPress={() => requestProviderUpdate(provider)}
329 />
330 ) : null}
331 </View>
332 ))}
333 </SettingsSection>
334 </>
335 ) : null}
336 </>
337 )}
338 </ScreenScrollView>
339 </SettingsScreen>
340 );
341}
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+13-6
...@@ -47,7 +47,6 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -47,7 +47,6 @@ export function SettingsEnvironmentsRouteScreen() {
47 const connectedCloudEnvironments = SHOWCASE_ENABLED47 const connectedCloudEnvironments = SHOWCASE_ENABLED
48 ? SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS48 ? SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS
49 : environmentSections.connectedCloudEnvironments;49 : environmentSections.connectedCloudEnvironments;
50 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
51 const headerIconColor = useUniwindTheme()["--color-icon"];50 const headerIconColor = useUniwindTheme()["--color-icon"];
52 const relaySession = useAtomValue(managedRelaySessionAtom);51 const relaySession = useAtomValue(managedRelaySessionAtom);
53 const refreshRelayEnvironments = useAtomCommand(52 const refreshRelayEnvironments = useAtomCommand(
...@@ -68,9 +67,15 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -68,9 +67,15 @@ export function SettingsEnvironmentsRouteScreen() {
68 }67 }
69 }68 }
7069
71 const handleToggle = useCallback((environmentId: EnvironmentId) => {70 const openEnvironment = useCallback(
72 setExpandedId((prev) => (prev === environmentId ? null : environmentId));71 (environmentId: EnvironmentId) => {
73 }, []);72 navigation.navigate("SettingsSheet", {
73 screen: "SettingsContent",
74 params: { screen: "SettingsEnvironmentDetail", params: { environmentId } },
75 });
76 },
77 [navigation],
78 );
74 const handleUpdateEnvironment = useCallback(79 const handleUpdateEnvironment = useCallback(
75 (80 (
76 environmentId: EnvironmentId,81 environmentId: EnvironmentId,
...@@ -159,8 +164,9 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -159,8 +164,9 @@ export function SettingsEnvironmentsRouteScreen() {
159 >164 >
160 <LocalEnvironmentList165 <LocalEnvironmentList
161 environments={localEnvironments}166 environments={localEnvironments}
162 expandedId={expandedId}167 expandedId={null}
163 onToggle={handleToggle}168 onToggle={openEnvironment}
169 opensDetails
164 onReconnect={onReconnectEnvironment}170 onReconnect={onReconnectEnvironment}
165 onRemove={onRemoveEnvironmentPress}171 onRemove={onRemoveEnvironmentPress}
166 onSetEnabled={onSetEnvironmentEnabled}172 onSetEnabled={onSetEnvironmentEnabled}
...@@ -172,6 +178,7 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -172,6 +178,7 @@ export function SettingsEnvironmentsRouteScreen() {
172 user is signed out — the component gates discovery itself. */}178 user is signed out — the component gates discovery itself. */}
173 <CloudEnvironmentRows179 <CloudEnvironmentRows
174 connectedCloudEnvironments={connectedCloudEnvironments}180 connectedCloudEnvironments={connectedCloudEnvironments}
181 onOpenEnvironment={openEnvironment}
175 onSetEnvironmentEnabled={onSetEnvironmentEnabled}182 onSetEnvironmentEnabled={onSetEnvironmentEnabled}
176 onRemoveEnvironment={onRemoveEnvironmentPress}183 onRemoveEnvironment={onRemoveEnvironmentPress}
177 {...(SHOWCASE_ENABLED184 {...(SHOWCASE_ENABLED
apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx+54-26
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { useNavigation } from "@react-navigation/native";
2import { SettingsRow } from "./components/SettingsRow";
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";3import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { SymbolView } from "../../components/AppSymbol";4import { SymbolView } from "../../components/AppSymbol";
3import { AppText as Text } from "../../components/AppText";5import { AppText as Text } from "../../components/AppText";
...@@ -133,6 +135,7 @@ export function SettingsEnvironmentMaintenanceRouteScreen() {...@@ -133,6 +135,7 @@ export function SettingsEnvironmentMaintenanceRouteScreen() {
133135
134function ServerSettingsDetail(props: { readonly page: SettingsPage }) {136function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
135 const insets = useSafeAreaInsets();137 const insets = useSafeAreaInsets();
138 const navigation = useNavigation();
136 const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter();139 const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter();
137 const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey);140 const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey);
138 const projectSelected = selectedProjectKey !== null;141 const projectSelected = selectedProjectKey !== null;
...@@ -397,36 +400,61 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {...@@ -397,36 +400,61 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
397 ) : null}400 ) : null}
398401
399 {props.page === "maintenance" ? (402 {props.page === "maintenance" ? (
400 <SettingsSection title="Updates">403 <>
401 <FanoutSwitchRow404 {!projectSelected ? (
402 icon="arrow.clockwise"405 <SettingsSection title="Manage environments">
403 label="Check provider updates"406 {selectedTargets.map((target) => (
404 subtitle={407 <SettingsRow
405 projectSelected408 key={target.environmentId}
406 ? "Environment-wide setting. Select All projects to change it."409 icon="server.rack"
407 : "Check installed provider CLIs for newer versions."410 label={target.label}
408 }411 value="Server and provider updates"
409 value={uniform("enableProviderUpdateChecks")}412 onPress={() =>
410 disabled={disabledFor("enableProviderUpdateChecks")}413 navigation.navigate("SettingsSheet", {
411 onValueChange={(value) => write({ enableProviderUpdateChecks: value })}414 screen: "SettingsContent",
412 />415 params: {
413 <View className="border-t border-border-subtle">416 screen: "SettingsEnvironmentDetail",
417 params: { environmentId: target.environmentId },
418 },
419 })
420 }
421 />
422 ))}
423 </SettingsSection>
424 ) : null}
425 <SettingsSection title="Updates">
414 <FanoutSwitchRow426 <FanoutSwitchRow
415 icon="arrow.uturn.forward"427 icon="arrow.clockwise"
416 label="Continue after restart"428 label="Check provider updates"
417 subtitle={429 subtitle={
418 supportsContinuation430 projectSelected
419 ? "Resume interrupted threads after an update or restart."431 ? "Environment-wide setting. Select All projects to change it."
420 : "Update older servers to control restart continuation."432 : "Check installed provider CLIs for newer versions."
421 }
422 value={uniform("continueThreadsAfterServerUpdate")}
423 disabled={
424 disabledFor("continueThreadsAfterServerUpdate") || !supportsContinuation
425 }433 }
426 onValueChange={(value) => write({ continueThreadsAfterServerUpdate: value })}434 value={uniform("enableProviderUpdateChecks")}
435 disabled={disabledFor("enableProviderUpdateChecks")}
436 onValueChange={(value) => write({ enableProviderUpdateChecks: value })}
427 />437 />
428 </View>438 <View className="border-t border-border-subtle">
429 </SettingsSection>439 <FanoutSwitchRow
440 icon="arrow.uturn.forward"
441 label="Continue after restart"
442 subtitle={
443 supportsContinuation
444 ? "Resume interrupted threads after an update or restart."
445 : "Update older servers to control restart continuation."
446 }
447 value={uniform("continueThreadsAfterServerUpdate")}
448 disabled={
449 disabledFor("continueThreadsAfterServerUpdate") || !supportsContinuation
450 }
451 onValueChange={(value) =>
452 write({ continueThreadsAfterServerUpdate: value })
453 }
454 />
455 </View>
456 </SettingsSection>
457 </>
430 ) : null}458 ) : null}
431 </>459 </>
432 )}460 )}
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+1
...@@ -17,6 +17,7 @@ export function SettingsSwitchRow(...@@ -17,6 +17,7 @@ export function SettingsSwitchRow(
17 subtitle={props.subtitle}17 subtitle={props.subtitle}
18 >18 >
19 <ThemedSwitch19 <ThemedSwitch
20 style={{ alignSelf: "center" }}
20 accessibilityLabel={props.label}21 accessibilityLabel={props.label}
21 disabled={props.disabled}22 disabled={props.disabled}
22 onValueChange={props.onValueChange}23 onValueChange={props.onValueChange}
apps/mobile/src/features/settings/environment-maintenance.test.ts created+154
...@@ -0,0 +1,154 @@
1import { AuthOrchestrationOperateScope, ServerProvider } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5import {
6 canMaintainEnvironment,
7 canUpdateEnvironmentProvider,
8 findEnvironmentUpdate,
9 supportsEnvironmentUpdate,
10} from "./environment-maintenance";
11
12const provider = Schema.decodeUnknownSync(ServerProvider)({
13 instanceId: "codex-personal",
14 driver: "codex",
15 enabled: true,
16 installed: true,
17 version: "1.0.0",
18 status: "ready",
19 auth: { status: "authenticated" },
20 checkedAt: "2026-09-23T00:00:00.000Z",
21 models: [],
22 versionAdvisory: {
23 status: "behind_latest",
24 currentVersion: "1.0.0",
25 latestVersion: "1.1.0",
26 canUpdate: true,
27 updateCommand: null,
28 checkedAt: null,
29 message: null,
30 },
31});
32
33afterEach(() => vi.unstubAllGlobals());
34
35describe("environment maintenance access", () => {
36 it("requires a connected authenticated session with operate permission", () => {
37 const session = {
38 authenticated: true,
39 auth: {
40 policy: "remote-reachable" as const,
41 bootstrapMethods: [],
42 sessionMethods: [],
43 sessionCookieName: "session",
44 },
45 scopes: [AuthOrchestrationOperateScope],
46 };
47 expect(canMaintainEnvironment(session, true)).toBe(true);
48 expect(canMaintainEnvironment(session, false)).toBe(false);
49 expect(canMaintainEnvironment({ ...session, authenticated: false }, true)).toBe(false);
50 expect(canMaintainEnvironment({ ...session, scopes: [] }, true)).toBe(false);
51 const { scopes: _, ...legacy } = session;
52 expect(canMaintainEnvironment(legacy, true)).toBe(false);
53 expect(canMaintainEnvironment(null, true)).toBe(false);
54 });
55
56 it("requires remote desktop update support for desktop hosts", () => {
57 expect(supportsEnvironmentUpdate({})).toBe(false);
58 expect(supportsEnvironmentUpdate({ serverSelfUpdate: "respawn" })).toBe(true);
59 expect(supportsEnvironmentUpdate({ serverSelfUpdate: "desktop-managed" })).toBe(false);
60 expect(
61 supportsEnvironmentUpdate({ serverSelfUpdate: "desktop-managed", desktopAppUpdate: true }),
62 ).toBe(true);
63 });
64
65 it("excludes unavailable, manual, busy, and incompatible provider updates", () => {
66 expect(canUpdateEnvironmentProvider(provider)).toBe(true);
67 expect(canUpdateEnvironmentProvider({ ...provider, installed: false })).toBe(false);
68 expect(canUpdateEnvironmentProvider({ ...provider, availability: "unavailable" })).toBe(false);
69 expect(canUpdateEnvironmentProvider({ ...provider, versionAdvisory: undefined })).toBe(false);
70 for (const latestVersionStatus of ["broken", "unsupported"] as const) {
71 expect(
72 canUpdateEnvironmentProvider({
73 ...provider,
74 compatibilityAdvisory: {
75 status: "supported",
76 latestVersionStatus,
77 message: null,
78 recommendedVersion: null,
79 recommendedRange: null,
80 },
81 }),
82 ).toBe(false);
83 }
84 for (const status of ["queued", "running"] as const) {
85 expect(
86 canUpdateEnvironmentProvider({
87 ...provider,
88 updateState: {
89 status,
90 startedAt: null,
91 finishedAt: null,
92 message: null,
93 output: null,
94 },
95 }),
96 ).toBe(false);
97 }
98 expect(
99 canUpdateEnvironmentProvider({
100 ...provider,
101 versionAdvisory: {
102 ...provider.versionAdvisory!,
103 canUpdate: false,
104 },
105 }),
106 ).toBe(false);
107 });
108});
109
110describe("environment release checks", () => {
111 const signal = new AbortController().signal;
112
113 it("keeps stable hosts on stable releases and ignores drafts", async () => {
114 vi.stubGlobal(
115 "fetch",
116 vi
117 .fn()
118 .mockImplementation(async () =>
119 Response.json([
120 { tag_name: "v2.0.0-nightly.20260923.1" },
121 { tag_name: "v1.2.0", draft: true },
122 { tag_name: "v1.1.0" },
123 ]),
124 ),
125 );
126 expect(await findEnvironmentUpdate("1.0.0", signal)).toBe("1.1.0");
127 expect(await findEnvironmentUpdate("1.1.0", signal)).toBeNull();
128 expect(await findEnvironmentUpdate("1.3.0", signal)).toBeNull();
129 });
130
131 it("walks release pages to find the host's channel", async () => {
132 const fetchMock = vi
133 .fn()
134 .mockResolvedValueOnce(
135 Response.json(Array.from({ length: 100 }, () => ({ tag_name: "v2.0.0" }))),
136 )
137 .mockResolvedValueOnce(Response.json([{ tag_name: "v1.0.0-preview.20260923.2" }]));
138 vi.stubGlobal("fetch", fetchMock);
139 expect(await findEnvironmentUpdate("1.0.0-preview.20260923.1", signal)).toBe(
140 "1.0.0-preview.20260923.2",
141 );
142 expect(fetchMock.mock.calls[1]?.[0]).toContain("page=2");
143 expect(fetchMock.mock.calls[1]?.[1]).toEqual({ signal });
144 });
145
146 it("reports failed checks instead of claiming the server is current", async () => {
147 vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 403 })));
148 await expect(findEnvironmentUpdate("1.0.0", signal)).rejects.toThrow("403");
149 vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json([])));
150 await expect(findEnvironmentUpdate("1.0.0", signal)).rejects.toThrow("No stable release");
151 vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ error: "invalid" })));
152 await expect(findEnvironmentUpdate("1.0.0", signal)).rejects.toThrow();
153 });
154});
apps/mobile/src/features/settings/environment-maintenance.ts created+68
...@@ -0,0 +1,68 @@
1import {
2 AuthOrchestrationOperateScope,
3 type AuthSessionState,
4 type ExecutionEnvironmentCapabilities,
5 type ServerProvider,
6} from "@t3tools/contracts";
7import {
8 cliReleaseChannelOf,
9 cliReleaseIndexPageUrl,
10 newestCliReleaseVersion,
11} from "@t3tools/shared/cliRelease";
12import { compareSemverVersions } from "@t3tools/shared/semver";
13import * as Schema from "effect/Schema";
14
15export function canMaintainEnvironment(session: AuthSessionState | null, connected: boolean) {
16 return (
17 connected &&
18 session?.authenticated === true &&
19 session.scopes?.includes(AuthOrchestrationOperateScope) === true
20 );
21}
22
23export function supportsEnvironmentUpdate(
24 capabilities: Pick<ExecutionEnvironmentCapabilities, "serverSelfUpdate" | "desktopAppUpdate">,
25) {
26 return (
27 capabilities.serverSelfUpdate !== undefined &&
28 (capabilities.serverSelfUpdate !== "desktop-managed" || capabilities.desktopAppUpdate === true)
29 );
30}
31
32export function canUpdateEnvironmentProvider(provider: ServerProvider) {
33 const compatibility = provider.compatibilityAdvisory?.latestVersionStatus;
34 return (
35 provider.installed &&
36 provider.availability !== "unavailable" &&
37 provider.versionAdvisory?.status === "behind_latest" &&
38 provider.versionAdvisory.canUpdate &&
39 provider.versionAdvisory.latestVersion !== null &&
40 compatibility !== "broken" &&
41 compatibility !== "unsupported" &&
42 provider.updateState?.status !== "running" &&
43 provider.updateState?.status !== "queued"
44 );
45}
46
47const Releases = Schema.Array(
48 Schema.Struct({
49 tag_name: Schema.String,
50 draft: Schema.optionalKey(Schema.Boolean),
51 }),
52);
53const decodeReleases = Schema.decodeUnknownSync(Releases);
54
55/** Preserve the host's release channel and never offer a downgrade. */
56export async function findEnvironmentUpdate(currentVersion: string, signal: AbortSignal) {
57 const channel = cliReleaseChannelOf(currentVersion);
58 for (let page = 1; ; page++) {
59 const response = await fetch(cliReleaseIndexPageUrl(page), { signal });
60 if (!response.ok) throw new Error(`Could not check releases (${response.status}). Try again.`);
61 const releases = decodeReleases(await response.json());
62 const version = newestCliReleaseVersion(releases, channel);
63 if (version !== undefined) {
64 return compareSemverVersions(version, currentVersion) > 0 ? version : null;
65 }
66 if (releases.length < 100) throw new Error(`No ${channel} release was found.`);
67 }
68}
docs/user/updating.md+12
...@@ -58,6 +58,18 @@ update can roll back to the previous version. If the update still fails:...@@ -58,6 +58,18 @@ update can roll back to the previous version. If the update still fails:
5858
59## Mobile updates59## Mobile updates
6060
61To update an environment from your phone, open **Settings → Environments** and
62select it. **Check for updates** finds the latest release on that environment's
63current release channel. Keep the app open while the environment updates and
64reconnects. Hosts that cannot update remotely show instructions for updating on
65the machine instead.
66
67The same page lets you refresh provider status and update supported providers.
68These controls require a connected environment and permission to operate it.
69Provider update checks and restart continuation preferences are in
70**Settings → Maintenance**. If provider update checks are disabled, enable them
71there before refreshing to find newer versions.
72
61Install App Store or Google Play releases as usual. The mobile app can also73Install App Store or Google Play releases as usual. The mobile app can also
62download updates in the background and apply them when you next leave the app.74download updates in the background and apply them when you next leave the app.
63It saves drafts and queued messages before restarting. If you keep the app open75It saves drafts and queued messages before restarting. If you keep the app open