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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions apps/mobile/src/features/settings/OutdatedServerUpdateSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useAtomValue } from "@effect/atom-react";
import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentId } from "@t3tools/contracts";
import { AsyncResult } from "effect/reactivity";
import { useEffect, useRef, useState } from "react";
import { View } from "react-native";

import { AppText as Text } from "../../components/ui/AppText";
import * as Dialog from "../../components/ui/Dialog";
import {
describeOutdatedServer,
serverEnvironment,
updateOutdatedServer,
} from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { SettingsActionRow } from "./components/SettingsActionRow";
import { SettingsSection } from "./components/SettingsSection";
import { findEnvironmentUpdate } from "./environment-maintenance";

export function OutdatedServerUpdateSection({
environmentId,
label,
}: {
readonly environmentId: EnvironmentId;
readonly label: string;
}) {
const describe = useAtomCommand(describeOutdatedServer, { reportFailure: false });
const update = useAtomCommand(updateOutdatedServer, { reportFailure: false });
const state = useAtomValue(serverEnvironment.updateStateAtom(environmentId));
const [release, setRelease] = useState<{
fromVersion: string;
targetVersion: string | null;
} | null>(null);
const [checking, setChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const controller = useRef<AbortController | null>(null);
useEffect(() => () => controller.current?.abort(), []);
const busy = checking || state.status === "running";

async function check() {
if (busy || controller.current !== null) return;
const request = new AbortController();
controller.current = request;
setChecking(true);
setError(null);
const timeout = setTimeout(() => request.abort(), 20_000);
const result = await settlePromise(async () => {
const description = await describe(environmentId);
if (AsyncResult.isFailure(description)) throw squashAtomCommandFailure(description);
const fromVersion = description.value.serverVersion;
const targetVersion = await findEnvironmentUpdate(fromVersion, request.signal);
if (!request.signal.aborted) setRelease({ fromVersion, targetVersion });
});
clearTimeout(timeout);
controller.current = null;
setChecking(false);
if (AsyncResult.isFailure(result)) {
const failure = squashAtomCommandFailure(result);
setError(failure instanceof Error ? failure.message : "Could not check for updates.");
}
}
Comment thread
maria-rcks marked this conversation as resolved.

function requestUpdate() {
const targetVersion = release?.targetVersion;
if (busy || !release || !targetVersion) return;
Dialog.alert(
`Update ${label}?`,
`Install T3 Code ${targetVersion}. T3 Code restarts on that machine and reconnects. Running threads may be interrupted.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Update",
onPress: () => {
setError(null);
void update({
environmentId,
input: { targetVersion },
fromVersion: release.fromVersion,
});
},
},
],
);
}

return (
<SettingsSection title="T3 Code">
<View className="gap-1 p-4">
<Text className="text-base text-foreground">
{release ? `Version ${release.fromVersion}` : "Update required"}
</Text>
<Text className="text-sm text-foreground-muted">
{state.status === "running"
? state.stage === "resuming"
? "Restarting and reconnecting…"
: state.stage === "installing"
? "Installing update…"
: "Downloading update…"
: release
? release.targetVersion
? `Version ${release.targetVersion} is available.`
: "No newer version is available yet."
: "This app needs a newer version of T3 Code on this machine."}
</Text>
{error || state.status === "failed" ? (
<Text selectable accessibilityRole="alert" className="text-sm text-danger-foreground">
{error ?? (state.status === "failed" ? state.message : null)}
</Text>
) : null}
</View>
<SettingsActionRow
icon="arrow.clockwise"
label="Check for updates"
disabled={busy}
loading={checking}
onPress={() => void check()}
/>
{release?.targetVersion ? (
<SettingsActionRow
icon="arrow.up.circle"
label={state.status === "failed" ? "Retry update" : `Update to ${release.targetVersion}`}
disabled={busy}
loading={state.status === "running"}
onPress={requestUpdate}
/>
) : null}
</SettingsSection>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ import { AppText as Text } from "../../components/ui/AppText";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ScreenScrollView } from "../../components/ui/ScreenScrollView";
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentPresentations } from "../../state/presentation";
import { serverEnvironment } from "../../state/server";
import { environmentSession, useEnvironmentScope, readEnvironmentScope } from "../../state/session";
import { useAtomCommand } from "../../state/use-atom-command";
import { useRemoteConnections } from "../../state/use-remote-environment-registry";
import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
import { EnvironmentRoutesSection } from "./EnvironmentRoutesSection";
import { OutdatedServerUpdateSection } from "./OutdatedServerUpdateSection";
import { SettingsActionRow } from "./components/SettingsActionRow";
import { SettingsScreen } from "./components/SettingsScreen";
import { SettingsSection } from "./components/SettingsSection";
Expand Down Expand Up @@ -58,6 +60,9 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
const session = useAtomValue(environmentSession.sessionStateValueAtom(environmentId));
const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId));
const updateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));
const outdated =
useAtomValue(environmentPresentations.presentationAtom(environmentId))?.entry
.serverUpdateRequired === true;
const updateServer = useAtomCommand(serverEnvironment.updateServer);
const updateProvider = useAtomCommand(serverEnvironment.updateProvider);
const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders);
Expand Down Expand Up @@ -206,7 +211,12 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
})
}
/>
{!connected ? (
{outdated ? (
<OutdatedServerUpdateSection
environmentId={environmentId}
label={environment.environmentLabel}
/>
) : !connected ? (
<Text className="px-2 text-sm text-foreground-muted">
Connect this environment to manage it.
</Text>
Expand All @@ -225,7 +235,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
</Text>
) : null}
{notice ? <Text className="px-2 text-sm text-foreground-muted">{notice}</Text> : null}
{config ? (
{config && !outdated ? (
<>
<SettingsSection title="T3 Code">
<View className="gap-1 p-4">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {
squashAtomCommandFailure,
type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
import type { ProviderAuthResponse, ServerProvider } from "@t3tools/contracts";
import {
AuthProvidersManageScope,
type ProviderAuthResponse,
type ServerProvider,
} from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { useRef, useState } from "react";
import { Linking, Pressable, ScrollView, TextInput, View } from "react-native";
Expand All @@ -15,6 +19,7 @@ import { ScreenScrollView } from "../../components/ui/ScreenScrollView";
import { environmentPresentations } from "../../state/presentation";
import { useEnvironmentQuery } from "../../state/query";
import { serverEnvironment } from "../../state/server";
import { useEnvironmentsWithScope } from "../../state/session";
import { useAtomCommand } from "../../state/use-atom-command";
import { SettingsActionRow } from "./components/SettingsActionRow";
import {
Expand All @@ -27,6 +32,7 @@ import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-en

export function SettingsProviderAccountsRouteScreen() {
const { selectedTargets } = useSettingsEnvironmentFilter();
const permittedEnvironments = useEnvironmentsWithScope(selectedTargets, AuthProvidersManageScope);
const providersByEnvironment = useAtomValue(environmentPresentations.providersByEnvironmentAtom);
const insets = useSafeAreaInsets();
return (
Expand All @@ -44,28 +50,39 @@ export function SettingsProviderAccountsRouteScreen() {
) : (
selectedTargets.map((environment) => (
<SettingsSection key={environment.environmentId} title={environment.label}>
{(providersByEnvironment.get(environment.environmentId) ?? [])
.filter(
(provider) =>
provider.setup?.canAuthenticate ||
(provider.driver === "acpRegistry" && provider.installed),
)
.map((provider) => (
<ProviderAccount
key={provider.instanceId}
environment={environment}
provider={provider}
/>
))}
{!(providersByEnvironment.get(environment.environmentId) ?? []).some(
(provider) =>
provider.setup?.canAuthenticate ||
(provider.driver === "acpRegistry" && provider.installed),
) ? (
<Text className="p-4 text-foreground-muted">
Configure a provider with in-app sign-in in web or desktop Settings.
</Text>
) : null}
{!permittedEnvironments.has(environment.environmentId) ? (
<View className="gap-1 p-4">
<Text className="text-base text-foreground">Limited permissions</Text>
<Text className="text-sm text-foreground-muted">
{`This session can view ${environment.label}'s providers but can't change their settings.`}
</Text>
</View>
) : (
<>
{(providersByEnvironment.get(environment.environmentId) ?? [])
.filter(
(provider) =>
provider.setup?.canAuthenticate ||
(provider.driver === "acpRegistry" && provider.installed),
)
.map((provider) => (
<ProviderAccount
key={provider.instanceId}
environment={environment}
provider={provider}
/>
))}
{!(providersByEnvironment.get(environment.environmentId) ?? []).some(
(provider) =>
provider.setup?.canAuthenticate ||
(provider.driver === "acpRegistry" && provider.installed),
) ? (
<Text className="p-4 text-foreground-muted">
Configure a provider with in-app sign-in in web or desktop Settings.
</Text>
) : null}
</>
)}
</SettingsSection>
))
)}
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/state/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server";
import {
createOutdatedServerDescriptionCommand,
createOutdatedServerUpdateCommand,
} from "@t3tools/client-runtime/state/outdatedServerUpdate";
import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell";

import { environmentCatalog } from "../connection/catalog";
Expand All @@ -11,6 +15,8 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt
usageLimitSources: true,
usageLimitsCommand: true,
});
export const describeOutdatedServer = createOutdatedServerDescriptionCommand(connectionAtomRuntime);
export const updateOutdatedServer = createOutdatedServerUpdateCommand(connectionAtomRuntime);
export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
catalogValueAtom: environmentCatalog.catalogValueAtom,
serverConfigValueAtom: serverEnvironment.configValueAtom,
Expand Down
23 changes: 23 additions & 0 deletions apps/mobile/src/state/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
AuthOrchestrationOperateScope,
AuthProvidersManageScope,
EnvironmentId,
type AuthSessionState,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -111,3 +112,25 @@ it("uses exact new-server permissions and keeps old-server grants usable", () =>
appAtomRegistry.set(source(primary), AsyncResult.success(session(true)));
expect(useEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(true);
});

it("limits provider accounts to environments granting provider management", () => {
const environments = [{ environmentId: primary }, { environmentId: secondary }];
const permitted = () => useEnvironmentsWithScope(environments, AuthProvidersManageScope);
appAtomRegistry.set(
source(primary),
AsyncResult.success({ ...session(true), permissions: [AuthOrchestrationOperateScope] }),
);
expect(permitted()).toEqual(new Set());
appAtomRegistry.set(
source(secondary),
AsyncResult.success({ ...session(false), permissions: [AuthProvidersManageScope] }),
);
expect(permitted()).toEqual(new Set([secondary]));
appAtomRegistry.set(source(primary), AsyncResult.success(session(true)));
expect(permitted()).toEqual(new Set([primary, secondary]));
appAtomRegistry.set(
source(secondary),
AsyncResult.success({ ...session(true), scopes: [AuthProvidersManageScope], permissions: [] }),
);
expect(permitted()).toEqual(new Set([primary]));
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import * as Socket from "effect/socket/Socket";
import type { ConnectionCatalogEntry } from "./catalog.ts";
import { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
import { PrimaryConnectionTarget } from "./model.ts";
import { updateOutdatedHost } from "./outdatedHostUpdate.ts";
import { describeOutdatedHost, updateOutdatedHost } from "./outdatedHostUpdate.ts";
import * as EnvironmentRegistry from "./registry.ts";
import * as ConnectionResolver from "./resolver.ts";
import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
Expand Down Expand Up @@ -49,6 +49,47 @@ const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown
const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
const encodeDescriptor = Schema.encodeSync(Schema.fromJsonString(ExecutionEnvironmentDescriptor));

describe("describeOutdatedHost", () => {
it.effect("discovers a disabled outdated host without opening a normal session", () =>
Effect.gen(function* () {
const expected = descriptor(undefined, "0.0.45");
const result = yield* describeOutdatedHost(TARGET.environmentId).pipe(
Effect.provideService(
EnvironmentRegistry.EnvironmentRegistry,
EnvironmentRegistry.EnvironmentRegistry.of({
entries: yield* SubscriptionRef.make<
ReadonlyMap<EnvironmentId, ConnectionCatalogEntry>
>(
new Map([
[TARGET.environmentId, { target: TARGET, profile: Option.none(), enabled: false }],
]),
),
} as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]),
),
Effect.provideService(
ConnectionResolver.ConnectionResolver,
ConnectionResolver.ConnectionResolver.of({
prepare: () => Effect.die(new Error("normal sessions reject this protocol")),
prepareForUpdate: (entry) =>
Effect.succeed({
descriptor: expected,
prepared: {
environmentId: entry.target.environmentId,
label: entry.target.label,
httpBaseUrl: TARGET.httpBaseUrl,
socketUrl: "wss://build.example.test/ws",
httpAuthorization: null,
target: TARGET,
},
}),
}),
),
);
expect(result).toEqual(expected);
}),
);
});

type Listener = (event: { readonly type: string; readonly data?: unknown }) => void;

class OutdatedHostSocket {
Expand Down
Loading
Loading