| author | |
| committer | |
| log | 68fb7f4b850991ba0ca5bd0d7c4aa13d4e5d6bb7 |
| tree | dc256aa25fd89f0d29253d9f33b2c36b74f23bfc |
| parent | 11e91f126405883c0194da5b1e3ad39d81c48966 |
| signature | Signed by PGP key B5690EEEBB952194 |
13 files changed, 723 insertions(+), 80 deletions(-)
.github/workflows/mobile-eas-preview.yml+13-1| ... | ... | @@ -78,7 +78,19 @@ jobs: |
| 78 | 78 | working-directory: apps/mobile |
| 79 | 79 | env: |
| 80 | 80 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} |
| 81 | run: eas env:pull preview --non-interactive | |
| 81 | 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 | |
| 82 | 94 | |
| 83 | 95 | - name: Deploy with fingerprint check |
| 84 | 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 | 71 | import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; |
| 72 | 72 | import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; |
| 73 | 73 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; |
| 74 | import { SettingsEnvironmentDetailRouteScreen } from "./features/settings/SettingsEnvironmentDetailRouteScreen"; | |
| 74 | 75 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 75 | 76 | import { |
| 76 | 77 | SettingsEnvironmentAgentBehaviorRouteScreen, |
| ... | ... | @@ -193,6 +194,11 @@ const SettingsContentStack = createNativeStackNavigator({ |
| 193 | 194 | title: "Environments", |
| 194 | 195 | }, |
| 195 | 196 | }), |
| 197 | SettingsEnvironmentDetail: createNativeStackScreen({ | |
| 198 | screen: SettingsEnvironmentDetailRouteScreen, | |
| 199 | linking: "environments/:environmentId", | |
| 200 | options: { title: "Environment" }, | |
| 201 | }), | |
| 196 | 202 | SettingsEnvironmentNewThreads: createNativeStackScreen({ |
| 197 | 203 | screen: SettingsEnvironmentNewThreadsRouteScreen, |
| 198 | 204 | linking: "new-threads", |
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+29-20| ... | ... | @@ -35,6 +35,7 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect |
| 35 | 35 | |
| 36 | 36 | interface CloudEnvironmentRowsProps { |
| 37 | 37 | readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; |
| 38 | readonly onOpenEnvironment?: (environmentId: EnvironmentId) => void; | |
| 38 | 39 | readonly onSetEnvironmentEnabled: (environmentId: EnvironmentId, enabled: boolean) => void; |
| 39 | 40 | /** Long-press on a saved row. The callback owns the confirm. */ |
| 40 | 41 | readonly onRemoveEnvironment: (environmentId: EnvironmentId) => void; |
| ... | ... | @@ -124,26 +125,30 @@ function CloudEnvironmentRowsContent( |
| 124 | 125 | ) : null} |
| 125 | 126 | |
| 126 | 127 | {hasCloudRows ? ( |
| 127 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | |
| 128 | {props.connectedCloudEnvironments.map((environment, index) => ( | |
| 128 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-grouped-card"> | |
| 129 | {props.connectedCloudEnvironments.map((environment) => ( | |
| 129 | 130 | <ConnectedCloudEnvironmentRow |
| 130 | 131 | key={environment.environmentId} |
| 131 | 132 | environment={environment} |
| 132 | 133 | descriptor={discoveredDescriptors.get(environment.environmentId)} |
| 133 | borderTop={index !== 0} | |
| 134 | 134 | onSetEnabled={(enabled) => |
| 135 | 135 | props.onSetEnvironmentEnabled(environment.environmentId, enabled) |
| 136 | 136 | } |
| 137 | 137 | onRemove={() => props.onRemoveEnvironment(environment.environmentId)} |
| 138 | onOpen={ | |
| 139 | props.onOpenEnvironment | |
| 140 | ? () => props.onOpenEnvironment?.(environment.environmentId) | |
| 141 | : undefined | |
| 142 | } | |
| 138 | 143 | errorExpanded={expandedErrorId === environment.environmentId} |
| 139 | 144 | onToggleError={() => handleToggleCloudError(environment.environmentId)} |
| 140 | 145 | /> |
| 141 | 146 | ))} |
| 142 | {availableCloudEnvironments.map((environment, index) => ( | |
| 147 | {availableCloudEnvironments.map((environment) => ( | |
| 143 | 148 | <CloudEnvironmentRow |
| 144 | 149 | key={environment.environment.environmentId} |
| 145 | 150 | environment={environment} |
| 146 | borderTop={props.connectedCloudEnvironments.length > 0 || index !== 0} | |
| 151 | showChevron={props.onOpenEnvironment !== undefined} | |
| 147 | 152 | onConnect={() => handleConnectCloudEnvironment(environment)} |
| 148 | 153 | errorExpanded={expandedErrorId === environment.environment.environmentId} |
| 149 | 154 | onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} |
| ... | ... | @@ -151,14 +156,14 @@ function CloudEnvironmentRowsContent( |
| 151 | 156 | ))} |
| 152 | 157 | </View> |
| 153 | 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 | 160 | <ActivityIndicator colorClassName={"accent-icon"} /> |
| 156 | 161 | <Text className="text-center text-sm leading-normal text-foreground-muted"> |
| 157 | 162 | Loading linked cloud environments. |
| 158 | 163 | </Text> |
| 159 | 164 | </View> |
| 160 | 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 | 167 | <Text className="text-sm leading-normal text-foreground-muted"> |
| 163 | 168 | No additional linked cloud environments. |
| 164 | 169 | </Text> |
| ... | ... | @@ -170,7 +175,7 @@ function CloudEnvironmentRowsContent( |
| 170 | 175 | {discoveryAvailable && |
| 171 | 176 | controller.relayDiscovery.error && |
| 172 | 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 | 179 | <Text className="text-base font-t3-bold text-foreground"> |
| 175 | 180 | Could not load T3 Connect environments |
| 176 | 181 | </Text> |
| ... | ... | @@ -202,10 +207,10 @@ function ConnectedCloudEnvironmentRow(props: { |
| 202 | 207 | readonly environment: ConnectedEnvironmentSummary; |
| 203 | 208 | /** Discovery's view of the server, for the glyph before the first connection. */ |
| 204 | 209 | readonly descriptor: ExecutionEnvironmentDescriptor | undefined; |
| 205 | readonly borderTop: boolean; | |
| 206 | 210 | readonly errorExpanded: boolean; |
| 207 | 211 | readonly onSetEnabled: (enabled: boolean) => void; |
| 208 | 212 | readonly onRemove: () => void; |
| 213 | readonly onOpen?: (() => void) | undefined; | |
| 209 | 214 | readonly onToggleError: () => void; |
| 210 | 215 | }) { |
| 211 | 216 | const serverConfig = useAtomValue( |
| ... | ... | @@ -222,10 +227,13 @@ function ConnectedCloudEnvironmentRow(props: { |
| 222 | 227 | return ( |
| 223 | 228 | <Pressable |
| 224 | 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 | 233 | onLongPress={props.onRemove} |
| 226 | 234 | > |
| 227 | 235 | <CloudEnvironmentRowShell |
| 228 | borderTop={props.borderTop} | |
| 236 | opensDetails={props.onOpen !== undefined} | |
| 229 | 237 | connectionError={enabled || unsupported ? props.environment.connectionError : null} |
| 230 | 238 | connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null} |
| 231 | 239 | connectionState={enabled || unsupported ? props.environment.connectionState : "available"} |
| ... | ... | @@ -246,7 +254,7 @@ function ConnectedCloudEnvironmentRow(props: { |
| 246 | 254 | |
| 247 | 255 | function CloudEnvironmentRow(props: { |
| 248 | 256 | readonly environment: RelayEnvironmentView; |
| 249 | readonly borderTop: boolean; | |
| 257 | readonly showChevron: boolean; | |
| 250 | 258 | readonly errorExpanded: boolean; |
| 251 | 259 | readonly onConnect: () => void; |
| 252 | 260 | readonly onToggleError: () => void; |
| ... | ... | @@ -260,7 +268,7 @@ function CloudEnvironmentRow(props: { |
| 260 | 268 | |
| 261 | 269 | return ( |
| 262 | 270 | <CloudEnvironmentRowShell |
| 263 | borderTop={props.borderTop} | |
| 271 | showChevron={props.showChevron} | |
| 264 | 272 | connectionError={presentation.connectionError} |
| 265 | 273 | connectionErrorTraceId={presentation.connectionErrorTraceId} |
| 266 | 274 | connectionState={presentation.connectionState} |
| ... | ... | @@ -285,7 +293,8 @@ function CloudEnvironmentRow(props: { |
| 285 | 293 | } |
| 286 | 294 | |
| 287 | 295 | function CloudEnvironmentRowShell(props: { |
| 288 | readonly borderTop: boolean; | |
| 296 | readonly showChevron?: boolean; | |
| 297 | readonly opensDetails?: boolean; | |
| 289 | 298 | readonly connectionError: string | null; |
| 290 | 299 | readonly connectionErrorTraceId: string | null; |
| 291 | 300 | readonly connectionState: EnvironmentConnectionPhase; |
| ... | ... | @@ -340,13 +349,7 @@ function CloudEnvironmentRowShell(props: { |
| 340 | 349 | [measuredErrorText, props.connectionError], |
| 341 | 350 | ); |
| 342 | 351 | return ( |
| 343 | <View | |
| 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 | > | |
| 352 | <View collapsable={false} className="flex-row items-center gap-3 bg-grouped-card px-4 py-3.5"> | |
| 350 | 353 | <View className="min-w-0 flex-1 gap-0.5"> |
| 351 | 354 | <View className="min-w-0 flex-row items-center gap-2"> |
| 352 | 355 | <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} /> |
| ... | ... | @@ -409,10 +412,16 @@ function CloudEnvironmentRowShell(props: { |
| 409 | 412 | </StatusContainer> |
| 410 | 413 | </View> |
| 411 | 414 | <ThemedSwitch |
| 415 | style={{ alignSelf: "center" }} | |
| 412 | 416 | disabled={props.disabled} |
| 413 | 417 | onValueChange={props.onValueChange} |
| 414 | 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 | 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 | 35 | export function ConnectionEnvironmentRow(props: { |
| 36 | 36 | readonly environment: ConnectedEnvironmentSummary; |
| 37 | 37 | readonly expanded: boolean; |
| 38 | readonly opensDetails?: boolean; | |
| 38 | 39 | readonly onToggle: () => void; |
| 39 | 40 | readonly onReconnect: (environmentId: EnvironmentId) => void; |
| 40 | 41 | readonly onRemove: (environmentId: EnvironmentId) => void; |
| ... | ... | @@ -76,19 +77,22 @@ export function ConnectionEnvironmentRow(props: { |
| 76 | 77 | }, [label, url, props]); |
| 77 | 78 | |
| 78 | 79 | return ( |
| 79 | <Animated.View layout={LinearTransition.duration(250)} className="bg-card"> | |
| 80 | <Animated.View layout={LinearTransition.duration(250)} className="bg-grouped-card"> | |
| 80 | 81 | <Pressable |
| 81 | 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 | 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 | 89 | <View className="flex-1 gap-0.5"> |
| 91 | 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 | 96 | <EnvironmentMachineSymbol |
| 93 | 97 | kind={resolveEnvironmentMachineKind(serverConfig)} |
| 94 | 98 | size={14} |
| ... | ... | @@ -101,9 +105,11 @@ export function ConnectionEnvironmentRow(props: { |
| 101 | 105 | {props.environment.environmentLabel} |
| 102 | 106 | </Text> |
| 103 | 107 | </View> |
| 104 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> | |
| 105 | {props.environment.displayUrl} | |
| 106 | </Text> | |
| 108 | {!props.environment.isRelayManaged && props.environment.displayUrl.trim() ? ( | |
| 109 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> | |
| 110 | {props.environment.displayUrl} | |
| 111 | </Text> | |
| 112 | ) : null} | |
| 107 | 113 | {statusLabel ? ( |
| 108 | 114 | <Text |
| 109 | 115 | className={cn( |
| ... | ... | @@ -126,12 +132,13 @@ export function ConnectionEnvironmentRow(props: { |
| 126 | 132 | </View> |
| 127 | 133 | |
| 128 | 134 | <ThemedSwitch |
| 135 | style={{ alignSelf: "center" }} | |
| 129 | 136 | disabled={unsupported} |
| 130 | 137 | onValueChange={(next) => props.onSetEnabled(props.environment.environmentId, next)} |
| 131 | 138 | value={enabled} |
| 132 | 139 | /> |
| 133 | 140 | <SymbolView |
| 134 | name="chevron.down" | |
| 141 | name={props.opensDetails ? "chevron.right" : "chevron.down"} | |
| 135 | 142 | size={12} |
| 136 | 143 | tintColorClassName="accent-icon-subtle" |
| 137 | 144 | type="monochrome" |
apps/mobile/src/features/connection/GitHubRoutingSettings.tsx+3-6| ... | ... | @@ -43,15 +43,12 @@ export function GitHubRoutingSettings() { |
| 43 | 43 | return ( |
| 44 | 44 | <View className="mt-5 gap-3"> |
| 45 | 45 | <SettingsSection title="GitHub routing"> |
| 46 | {[...catalog.entries.values()].map((entry, index) => { | |
| 46 | {[...catalog.entries.values()].map((entry) => { | |
| 47 | 47 | const environmentId = entry.target.environmentId; |
| 48 | 48 | const selected = gitHubRoutingPermissionFor(entry, permissions); |
| 49 | 49 | const disabled = !catalog.isReady || saving || gitHubRoutingConnectionKey(entry) === null; |
| 50 | 50 | return ( |
| 51 | <View | |
| 52 | key={environmentId} | |
| 53 | className={index === 0 ? undefined : "border-t border-border-subtle"} | |
| 54 | > | |
| 51 | <View key={environmentId}> | |
| 55 | 52 | <Pressable |
| 56 | 53 | accessibilityRole="button" |
| 57 | 54 | accessibilityLabel={`${entry.target.label} GitHub routing`} |
| ... | ... | @@ -83,7 +80,7 @@ export function GitHubRoutingSettings() { |
| 83 | 80 | accessibilityRole="radio" |
| 84 | 81 | accessibilityState={{ checked: selected === option.value, disabled }} |
| 85 | 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 | 84 | onPress={() => { |
| 88 | 85 | setSaving(true); |
| 89 | 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 | 4 | |
| 5 | 5 | import { SymbolView } from "../../components/AppSymbol"; |
| 6 | 6 | import { AppText as Text } from "../../components/AppText"; |
| 7 | import { cn } from "../../lib/cn"; | |
| 8 | 7 | import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow"; |
| 9 | 8 | |
| 10 | 9 | type EnvironmentRowProps = ComponentProps<typeof ConnectionEnvironmentRow>; |
| ... | ... | @@ -15,14 +14,20 @@ export function LocalEnvironmentList({ |
| 15 | 14 | expandedId, |
| 16 | 15 | onToggle, |
| 17 | 16 | ...rowActions |
| 18 | }: Pick<EnvironmentRowProps, "onReconnect" | "onRemove" | "onSetEnabled" | "onUpdate"> & { | |
| 17 | }: Pick< | |
| 18 | EnvironmentRowProps, | |
| 19 | "onReconnect" | "onRemove" | "onSetEnabled" | "onUpdate" | "opensDetails" | |
| 20 | > & { | |
| 19 | 21 | readonly environments: ReadonlyArray<EnvironmentRowProps["environment"]>; |
| 20 | 22 | readonly expandedId: EnvironmentId | null; |
| 21 | 23 | readonly onToggle: (environmentId: EnvironmentId) => void; |
| 22 | 24 | }) { |
| 23 | 25 | if (environments.length === 0) { |
| 24 | 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 | 31 | <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle"> |
| 27 | 32 | <SymbolView |
| 28 | 33 | name="point.3.connected.trianglepath.dotted" |
| ... | ... | @@ -40,13 +45,9 @@ export function LocalEnvironmentList({ |
| 40 | 45 | } |
| 41 | 46 | |
| 42 | 47 | return ( |
| 43 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | |
| 44 | {environments.map((environment, index) => ( | |
| 45 | <View | |
| 46 | key={environment.environmentId} | |
| 47 | collapsable={false} | |
| 48 | className={cn(index !== 0 && "border-t border-border")} | |
| 49 | > | |
| 48 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-grouped-card"> | |
| 49 | {environments.map((environment) => ( | |
| 50 | <View key={environment.environmentId} collapsable={false}> | |
| 50 | 51 | <ConnectionEnvironmentRow |
| 51 | 52 | environment={environment} |
| 52 | 53 | expanded={expandedId === environment.environmentId} |
apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx created+341| ... | ... | @@ -0,0 +1,341 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import type { StaticScreenProps } from "@react-navigation/native"; | |
| 3 | import type { EnvironmentId, ServerProvider } from "@t3tools/contracts"; | |
| 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 5 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 6 | import { useEffect, useRef, useState } from "react"; | |
| 7 | import { Alert, View } from "react-native"; | |
| 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 9 | ||
| 10 | import { AppText as Text } from "../../components/AppText"; | |
| 11 | import { ProviderIcon } from "../../components/ProviderIcon"; | |
| 12 | import { ScreenScrollView } from "../../components/ScreenScrollView"; | |
| 13 | import { serverEnvironment } from "../../state/server"; | |
| 14 | import { environmentSession } from "../../state/session"; | |
| 15 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 16 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | |
| 17 | import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; | |
| 18 | import { SettingsActionRow } from "./components/SettingsActionRow"; | |
| 19 | import { SettingsScreen } from "./components/SettingsScreen"; | |
| 20 | import { SettingsSection } from "./components/SettingsSection"; | |
| 21 | import { | |
| 22 | canMaintainEnvironment, | |
| 23 | canUpdateEnvironmentProvider, | |
| 24 | findEnvironmentUpdate, | |
| 25 | supportsEnvironmentUpdate, | |
| 26 | } from "./environment-maintenance"; | |
| 27 | ||
| 28 | export 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 | ||
| 42 | function 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 | 47 | const connectedCloudEnvironments = SHOWCASE_ENABLED |
| 48 | 48 | ? SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS |
| 49 | 49 | : environmentSections.connectedCloudEnvironments; |
| 50 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); | |
| 51 | 50 | const headerIconColor = useUniwindTheme()["--color-icon"]; |
| 52 | 51 | const relaySession = useAtomValue(managedRelaySessionAtom); |
| 53 | 52 | const refreshRelayEnvironments = useAtomCommand( |
| ... | ... | @@ -68,9 +67,15 @@ export function SettingsEnvironmentsRouteScreen() { |
| 68 | 67 | } |
| 69 | 68 | } |
| 70 | 69 | |
| 71 | const handleToggle = useCallback((environmentId: EnvironmentId) => { | |
| 72 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); | |
| 73 | }, []); | |
| 70 | const openEnvironment = useCallback( | |
| 71 | (environmentId: EnvironmentId) => { | |
| 72 | navigation.navigate("SettingsSheet", { | |
| 73 | screen: "SettingsContent", | |
| 74 | params: { screen: "SettingsEnvironmentDetail", params: { environmentId } }, | |
| 75 | }); | |
| 76 | }, | |
| 77 | [navigation], | |
| 78 | ); | |
| 74 | 79 | const handleUpdateEnvironment = useCallback( |
| 75 | 80 | ( |
| 76 | 81 | environmentId: EnvironmentId, |
| ... | ... | @@ -159,8 +164,9 @@ export function SettingsEnvironmentsRouteScreen() { |
| 159 | 164 | > |
| 160 | 165 | <LocalEnvironmentList |
| 161 | 166 | environments={localEnvironments} |
| 162 | expandedId={expandedId} | |
| 163 | onToggle={handleToggle} | |
| 167 | expandedId={null} | |
| 168 | onToggle={openEnvironment} | |
| 169 | opensDetails | |
| 164 | 170 | onReconnect={onReconnectEnvironment} |
| 165 | 171 | onRemove={onRemoveEnvironmentPress} |
| 166 | 172 | onSetEnabled={onSetEnvironmentEnabled} |
| ... | ... | @@ -172,6 +178,7 @@ export function SettingsEnvironmentsRouteScreen() { |
| 172 | 178 | user is signed out — the component gates discovery itself. */} |
| 173 | 179 | <CloudEnvironmentRows |
| 174 | 180 | connectedCloudEnvironments={connectedCloudEnvironments} |
| 181 | onOpenEnvironment={openEnvironment} | |
| 175 | 182 | onSetEnvironmentEnabled={onSetEnvironmentEnabled} |
| 176 | 183 | onRemoveEnvironment={onRemoveEnvironmentPress} |
| 177 | 184 | {...(SHOWCASE_ENABLED |
apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx+54-26| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { useNavigation } from "@react-navigation/native"; | |
| 2 | import { SettingsRow } from "./components/SettingsRow"; | |
| 1 | 3 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | 4 | import { SymbolView } from "../../components/AppSymbol"; |
| 3 | 5 | import { AppText as Text } from "../../components/AppText"; |
| ... | ... | @@ -133,6 +135,7 @@ export function SettingsEnvironmentMaintenanceRouteScreen() { |
| 133 | 135 | |
| 134 | 136 | function ServerSettingsDetail(props: { readonly page: SettingsPage }) { |
| 135 | 137 | const insets = useSafeAreaInsets(); |
| 138 | const navigation = useNavigation(); | |
| 136 | 139 | const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); |
| 137 | 140 | const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); |
| 138 | 141 | const projectSelected = selectedProjectKey !== null; |
| ... | ... | @@ -397,36 +400,61 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { |
| 397 | 400 | ) : null} |
| 398 | 401 | |
| 399 | 402 | {props.page === "maintenance" ? ( |
| 400 | <SettingsSection title="Updates"> | |
| 401 | <FanoutSwitchRow | |
| 402 | icon="arrow.clockwise" | |
| 403 | label="Check provider updates" | |
| 404 | subtitle={ | |
| 405 | projectSelected | |
| 406 | ? "Environment-wide setting. Select All projects to change it." | |
| 407 | : "Check installed provider CLIs for newer versions." | |
| 408 | } | |
| 409 | value={uniform("enableProviderUpdateChecks")} | |
| 410 | disabled={disabledFor("enableProviderUpdateChecks")} | |
| 411 | onValueChange={(value) => write({ enableProviderUpdateChecks: value })} | |
| 412 | /> | |
| 413 | <View className="border-t border-border-subtle"> | |
| 403 | <> | |
| 404 | {!projectSelected ? ( | |
| 405 | <SettingsSection title="Manage environments"> | |
| 406 | {selectedTargets.map((target) => ( | |
| 407 | <SettingsRow | |
| 408 | key={target.environmentId} | |
| 409 | icon="server.rack" | |
| 410 | label={target.label} | |
| 411 | value="Server and provider updates" | |
| 412 | onPress={() => | |
| 413 | navigation.navigate("SettingsSheet", { | |
| 414 | screen: "SettingsContent", | |
| 415 | params: { | |
| 416 | screen: "SettingsEnvironmentDetail", | |
| 417 | params: { environmentId: target.environmentId }, | |
| 418 | }, | |
| 419 | }) | |
| 420 | } | |
| 421 | /> | |
| 422 | ))} | |
| 423 | </SettingsSection> | |
| 424 | ) : null} | |
| 425 | <SettingsSection title="Updates"> | |
| 414 | 426 | <FanoutSwitchRow |
| 415 | icon="arrow.uturn.forward" | |
| 416 | label="Continue after restart" | |
| 427 | icon="arrow.clockwise" | |
| 428 | label="Check provider updates" | |
| 417 | 429 | subtitle={ |
| 418 | supportsContinuation | |
| 419 | ? "Resume interrupted threads after an update or restart." | |
| 420 | : "Update older servers to control restart continuation." | |
| 421 | } | |
| 422 | value={uniform("continueThreadsAfterServerUpdate")} | |
| 423 | disabled={ | |
| 424 | disabledFor("continueThreadsAfterServerUpdate") || !supportsContinuation | |
| 430 | projectSelected | |
| 431 | ? "Environment-wide setting. Select All projects to change it." | |
| 432 | : "Check installed provider CLIs for newer versions." | |
| 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> | |
| 429 | </SettingsSection> | |
| 438 | <View className="border-t border-border-subtle"> | |
| 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 | 458 | ) : null} |
| 431 | 459 | </> |
| 432 | 460 | )} |
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+1| ... | ... | @@ -17,6 +17,7 @@ export function SettingsSwitchRow( |
| 17 | 17 | subtitle={props.subtitle} |
| 18 | 18 | > |
| 19 | 19 | <ThemedSwitch |
| 20 | style={{ alignSelf: "center" }} | |
| 20 | 21 | accessibilityLabel={props.label} |
| 21 | 22 | disabled={props.disabled} |
| 22 | 23 | onValueChange={props.onValueChange} |
apps/mobile/src/features/settings/environment-maintenance.test.ts created+154| ... | ... | @@ -0,0 +1,154 @@ |
| 1 | import { AuthOrchestrationOperateScope, ServerProvider } from "@t3tools/contracts"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { | |
| 6 | canMaintainEnvironment, | |
| 7 | canUpdateEnvironmentProvider, | |
| 8 | findEnvironmentUpdate, | |
| 9 | supportsEnvironmentUpdate, | |
| 10 | } from "./environment-maintenance"; | |
| 11 | ||
| 12 | const 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 | ||
| 33 | afterEach(() => vi.unstubAllGlobals()); | |
| 34 | ||
| 35 | describe("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 | ||
| 110 | describe("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 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | type AuthSessionState, | |
| 4 | type ExecutionEnvironmentCapabilities, | |
| 5 | type ServerProvider, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | cliReleaseChannelOf, | |
| 9 | cliReleaseIndexPageUrl, | |
| 10 | newestCliReleaseVersion, | |
| 11 | } from "@t3tools/shared/cliRelease"; | |
| 12 | import { compareSemverVersions } from "@t3tools/shared/semver"; | |
| 13 | import * as Schema from "effect/Schema"; | |
| 14 | ||
| 15 | export function canMaintainEnvironment(session: AuthSessionState | null, connected: boolean) { | |
| 16 | return ( | |
| 17 | connected && | |
| 18 | session?.authenticated === true && | |
| 19 | session.scopes?.includes(AuthOrchestrationOperateScope) === true | |
| 20 | ); | |
| 21 | } | |
| 22 | ||
| 23 | export 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 | ||
| 32 | export 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 | ||
| 47 | const Releases = Schema.Array( | |
| 48 | Schema.Struct({ | |
| 49 | tag_name: Schema.String, | |
| 50 | draft: Schema.optionalKey(Schema.Boolean), | |
| 51 | }), | |
| 52 | ); | |
| 53 | const decodeReleases = Schema.decodeUnknownSync(Releases); | |
| 54 | ||
| 55 | /** Preserve the host's release channel and never offer a downgrade. */ | |
| 56 | export 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 | 58 | |
| 59 | 59 | ## Mobile updates |
| 60 | 60 | |
| 61 | To update an environment from your phone, open **Settings → Environments** and | |
| 62 | select it. **Check for updates** finds the latest release on that environment's | |
| 63 | current release channel. Keep the app open while the environment updates and | |
| 64 | reconnects. Hosts that cannot update remotely show instructions for updating on | |
| 65 | the machine instead. | |
| 66 | ||
| 67 | The same page lets you refresh provider status and update supported providers. | |
| 68 | These controls require a connected environment and permission to operate it. | |
| 69 | Provider update checks and restart continuation preferences are in | |
| 70 | **Settings → Maintenance**. If provider update checks are disabled, enable them | |
| 71 | there before refreshing to find newer versions. | |
| 72 | ||
| 61 | 73 | Install App Store or Google Play releases as usual. The mobile app can also |
| 62 | 74 | download updates in the background and apply them when you next leave the app. |
| 63 | 75 | It saves drafts and queued messages before restarting. If you keep the app open |