diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts deleted file mode 100644 index 3f1fe66908e93..0000000000000 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { beforeEach, vi } from "vite-plus/test"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; -import * as Layer from "effect/Layer"; -import { HttpClient } from "effect/http"; - -import type { SavedRemoteConnection } from "../../lib/connection"; -import * as MobileStorage from "../../persistence/mobile-storage"; -import { - CloudEnvironmentLinkError, - linkEnvironmentToCloudWithPreference, -} from "../cloud/linkEnvironment"; -import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; -import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; - -vi.mock("expo-secure-store", () => ({ - deleteItemAsync: vi.fn(), - getItemAsync: vi.fn(), - setItemAsync: vi.fn(), -})); - -vi.mock("react-native", () => ({ - Platform: { OS: "ios" }, -})); - -vi.mock("expo-constants", () => ({ - default: { expoConfig: {} }, -})); - -vi.mock("expo-device", () => ({})); - -vi.mock("../cloud/linkEnvironment", async (importOriginal) => ({ - ...(await importOriginal()), - linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void), -})); - -vi.mock("./remoteRegistration", () => ({ - updateAgentAwarenessRegistrationPreferences: vi.fn(() => Effect.void), -})); - -const connection: SavedRemoteConnection = { - environmentId: "env-1" as EnvironmentId, - environmentLabel: "Desktop", - pairingUrl: "https://desktop.example.test/", - displayUrl: "https://desktop.example.test/", - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - bearerToken: "local-bearer", -}; - -const layerTest = Layer.mergeAll( - Layer.succeed(ManagedRelay.ManagedRelayClient, null as never), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected HTTP request")), - ), - Layer.succeed( - MobileStorage.MobileStorage, - MobileStorage.MobileStorage.of({ - loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"), - loadAgentAwarenessDeviceId: Effect.succeed("device-1"), - loadAgentAwarenessRegistrationRecord: Effect.succeed(null), - saveAgentAwarenessRegistrationRecord: () => Effect.void, - clearAgentAwarenessRegistrationRecord: Effect.void, - loadRecentThreadShortcuts: Effect.succeed([]), - saveRecentThreadShortcuts: () => Effect.void, - }), - ), -); - -describe("liveActivityPreferences", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it.effect("restores relay preferences when an environment update fails", () => { - vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() => - Effect.fail(new CloudEnvironmentLinkError({ message: "environment update failed" })), - ); - - return Effect.gen(function* () { - const exit = yield* Effect.exit( - setLiveActivityUpdatesEnabled({ - enabled: false, - previousEnabled: true, - clerkToken: "clerk-token", - connections: [connection], - canConfigureEnvironment: () => true, - }), - ); - - expect(exit._tag).toBe("Failure"); - expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, { - liveActivitiesEnabled: false, - }); - expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, { - liveActivitiesEnabled: true, - }); - expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(1, { - clerkToken: "clerk-token", - connection, - liveActivitiesEnabled: false, - }); - expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(2, { - clerkToken: "clerk-token", - connection, - liveActivitiesEnabled: true, - }); - }).pipe(Effect.provide(layerTest)); - }); - - it.effect("only re-links environments with relay access when enabling updates", () => { - const readOnlyConnection: SavedRemoteConnection = { - ...connection, - environmentId: "read-only" as EnvironmentId, - }; - - return Effect.gen(function* () { - yield* setLiveActivityUpdatesEnabled({ - enabled: true, - previousEnabled: false, - clerkToken: "clerk-token", - connections: [readOnlyConnection, connection], - canConfigureEnvironment: (environmentId) => environmentId === connection.environmentId, - }); - - expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ - liveActivitiesEnabled: true, - }); - expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledExactlyOnceWith({ - clerkToken: "clerk-token", - connection, - liveActivitiesEnabled: true, - }); - }).pipe(Effect.provide(layerTest)); - }); - - it.effect("checks current relay access after updating the device registration", () => { - let canConfigureEnvironment = true; - vi.mocked(updateAgentAwarenessRegistrationPreferences).mockImplementationOnce(() => - Effect.sync(() => { - canConfigureEnvironment = false; - }), - ); - - return Effect.gen(function* () { - yield* setLiveActivityUpdatesEnabled({ - enabled: false, - previousEnabled: true, - clerkToken: "clerk-token", - connections: [connection], - canConfigureEnvironment: () => canConfigureEnvironment, - }); - - expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled(); - expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledTimes(1); - }).pipe(Effect.provide(layerTest)); - }); -}); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts deleted file mode 100644 index 826edaf839b91..0000000000000 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ /dev/null @@ -1,80 +0,0 @@ -import * as Effect from "effect/Effect"; -import type { EnvironmentId } from "@t3tools/contracts"; - -import type { SavedRemoteConnection } from "../../lib/connection"; -import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; -import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; - -export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEnabled")( - function* (input: { - readonly enabled: boolean; - readonly previousEnabled: boolean; - readonly clerkToken: string | null; - readonly connections: ReadonlyArray; - readonly canConfigureEnvironment: (environmentId: EnvironmentId) => boolean; - }) { - const linkedConnections = input.connections.filter( - (connection) => connection.bearerToken !== null, - ); - - const updateEnvironmentPreference = Effect.fn("updateEnvironmentPreference")(function* ( - connection: SavedRemoteConnection, - enabled: boolean, - clerkToken: string, - ) { - if (!input.canConfigureEnvironment(connection.environmentId)) return; - - yield* linkEnvironmentToCloudWithPreference({ - clerkToken, - connection, - liveActivitiesEnabled: enabled, - }); - }); - - const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) { - yield* updateAgentAwarenessRegistrationPreferences({ - liveActivitiesEnabled: enabled, - }); - - const clerkToken = input.clerkToken; - if (!clerkToken) return; - - yield* Effect.forEach( - linkedConnections, - (connection) => updateEnvironmentPreference(connection, enabled, clerkToken), - { concurrency: "unbounded" }, - ); - }); - - const restoreRelayPreference = Effect.fn("restoreRelayPreference")(function* () { - yield* updateAgentAwarenessRegistrationPreferences({ - liveActivitiesEnabled: input.previousEnabled, - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Could not restore Live Activity device preference.", cause), - ), - ); - - const clerkToken = input.clerkToken; - if (!clerkToken) return; - - yield* Effect.forEach( - linkedConnections, - (connection) => - updateEnvironmentPreference(connection, input.previousEnabled, clerkToken).pipe( - Effect.catchCause((cause) => - Effect.logWarning( - `Could not restore Live Activity preference for environment ${connection.environmentId}.`, - cause, - ), - ), - ), - { concurrency: "unbounded" }, - ); - }); - - yield* updateRelayPreference(input.enabled).pipe( - Effect.onError(() => restoreRelayPreference()), - ); - }, -); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 9085f98058066..d705d740e2bda 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -774,6 +774,128 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }, ); + it.effect("keeps Android activity off when the relay rejects turning it on", () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + vi.spyOn(Platform, "Version", "get").mockReturnValue(36); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "android", + data: "fcm-token", + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/v1/client/dpop-token")) { + return Response.json({ + access_token: "dpop", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + }); + } + return new Response("Service unavailable", { status: 503 }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(vi.mocked(configureAndroidAgentNotifications).mock.lastCall).toEqual([ + expect.any(String), + "user-a", + false, + ]); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(layerRelayTest), + ); + }); + + it.effect("keeps Android activity off when reading notification permission fails", () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + vi.spyOn(Platform, "Version", "get").mockReturnValue(36); + vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce( + new Error("permission lookup failed"), + ); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(vi.mocked(configureAndroidAgentNotifications).mock.lastCall).toEqual([ + expect.any(String), + "user-a", + false, + ]); + }).pipe(Effect.provide(layerRelayTest)); + }); + + it.effect("keeps a newer Android activity toggle when an older registration fails", () => { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + vi.spyOn(Platform, "Version", "get").mockReturnValue(36); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: "android", + data: "fcm-token", + }); + let olderRequestArrived!: () => void; + const olderArrived = new Promise((resolve) => { + olderRequestArrived = resolve; + }); + let failOlderRequest!: () => void; + const olderFails = new Promise((resolve) => { + failOlderRequest = resolve; + }); + let deviceRequests = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/v1/client/dpop-token")) { + return Response.json({ + access_token: "dpop", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + }); + } + deviceRequests += 1; + if (deviceRequests === 1) { + olderRequestArrived(); + await olderFails; + return new Response("Service unavailable", { status: 503 }); + } + return Response.json({ ok: true }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); + + return Effect.gen(function* () { + const older = yield* Effect.forkChild( + Effect.exit(updateAgentAwarenessRegistrationPreferences({})), + ); + yield* Effect.promise(() => olderArrived); + + yield* updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }); + failOlderRequest(); + expect(Exit.isFailure(yield* Fiber.join(older))).toBe(true); + + expect(vi.mocked(configureAndroidAgentNotifications).mock.lastCall).toEqual([ + expect.any(String), + "user-a", + true, + ]); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(layerRelayTest), + ); + }); + it.effect("preserves relay rejection errors with React Native response headers", () => { vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index ec712608fad94..723a8a0447a95 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -126,6 +126,7 @@ let activeLiveActivityRegistrationRetry: ReturnType | null = let relayTokenProvider: (() => Promise) | null = null; let relayTokenProviderIdentity: string | null = null; let deviceRegistrationGeneration = 0; +let androidConfigurationWrite = 0; let activeDeviceRegistration: { readonly input: DeviceRegistrationInput; operation: Promise; @@ -692,14 +693,32 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* input.preferencesOverride, ); if (expectedGeneration !== deviceRegistrationGeneration) return; - if (relayTokenProvider && relayTokenProviderIdentity) { - configureAndroidAgentNotifications( - deviceId, - relayTokenProviderIdentity, - preferences.liveActivitiesEnabled !== false, - ); - } - const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); + const configureAndroid = (registered: Preferences) => { + if (relayTokenProvider && relayTokenProviderIdentity) { + configureAndroidAgentNotifications( + deviceId, + relayTokenProviderIdentity, + registered.liveActivitiesEnabled !== false, + ); + } + }; + configureAndroid(preferences); + const configurationWrite = ++androidConfigurationWrite; + const restoreAndroidPreferences = Effect.sync(() => { + if ( + expectedGeneration === deviceRegistrationGeneration && + configurationWrite === androidConfigurationWrite + ) { + configureAndroid(storedPreferences); + } + }); + // A failed registration must not leave its preference applied natively while the relay keeps + // the old one, unless a newer registration has configured the device since. + const restoreAndroidOnError = (effect: Effect.Effect): Effect.Effect => + Effect.onError(effect, () => restoreAndroidPreferences); + const pushTokenRegistration = yield* restoreAndroidOnError( + nativePushTokenRegistration(input?.observedPushToken), + ); const bundleId = Platform.OS === "android" ? Constants.expoConfig?.android?.package?.trim() @@ -727,7 +746,7 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* preferences, }), expectedGeneration, - ); + ).pipe(restoreAndroidOnError); }); function registerDeviceForCurrentUser(): Effect.Effect< diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts deleted file mode 100644 index 2f9618e0953cd..0000000000000 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { beforeEach, vi } from "vite-plus/test"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { EnvironmentId } from "@t3tools/contracts"; -import { RelayMobileClientId } from "@t3tools/contracts/relay"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; -import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; -import { HttpClient } from "effect/http"; - -import * as MobileStorage from "../../persistence/mobile-storage"; - -import { linkEnvironmentToCloudWithPreference } from "./linkEnvironment"; - -vi.mock("expo-constants", () => ({ - default: { - expoConfig: { - extra: { - relay: { - url: "https://relay.example.test", - }, - }, - }, - }, -})); - -vi.mock("expo-device", () => ({ - deviceType: 1, - DeviceType: { - UNKNOWN: 0, - PHONE: 1, - TABLET: 2, - DESKTOP: 3, - TV: 4, - }, - osVersion: "18.4.1", - modelName: "iPhone 15 Pro", -})); - -vi.mock("react-native", () => ({ - Platform: { - OS: "ios", - }, -})); - -vi.mock("expo-secure-store", () => ({ - deleteItemAsync: vi.fn(), - getItemAsync: vi.fn(), - setItemAsync: vi.fn(), -})); - -const savedConnection = { - environmentId: EnvironmentId.make("env-1"), - environmentLabel: "Desktop", - pairingUrl: "https://desktop.example.test/", - displayUrl: "https://desktop.example.test/", - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - bearerToken: "local-bearer", -}; - -const createProofMock = vi.fn( - (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => - Effect.succeed(`dpop:${input.method}:${input.url}`), -); -const layerTestDpopSigner = Layer.succeed( - ManagedRelay.ManagedRelayDpopSigner, - ManagedRelay.ManagedRelayDpopSigner.of({ - thumbprint: Effect.succeed("client-proof-key-thumbprint"), - createProof: (input) => createProofMock(input), - }), -); - -function layerCloudClient() { - const layerHttpClient = layerRemoteHttpClient((input, init) => globalThis.fetch(input, init)); - return Layer.mergeAll( - layerHttpClient, - Layer.succeed( - MobileStorage.MobileStorage, - MobileStorage.MobileStorage.of({ - loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"), - loadAgentAwarenessDeviceId: Effect.succeed("device-1"), - loadAgentAwarenessRegistrationRecord: Effect.succeed(null), - saveAgentAwarenessRegistrationRecord: () => Effect.void, - clearAgentAwarenessRegistrationRecord: Effect.void, - loadRecentThreadShortcuts: Effect.succeed([]), - saveRecentThreadShortcuts: () => Effect.void, - }), - ), - ManagedRelay.layer({ - relayUrl: "https://relay.example.test", - clientId: RelayMobileClientId, - }).pipe(Layer.provideMerge(layerTestDpopSigner), Layer.provide(layerHttpClient)), - ); -} - -const withCloudServices = ( - effect: Effect.Effect< - A, - E, - | HttpClient.HttpClient - | ManagedRelay.ManagedRelayClient - | ManagedRelay.ManagedRelayDpopSigner - | MobileStorage.MobileStorage - >, -) => effect.pipe(Effect.provide(layerCloudClient())); - -function validLinkProof() { - return "signed-environment-link-jwt"; -} - -function validLinkResponse(environmentId = "env-1") { - return { - ok: true, - environmentId, - endpoint: { - httpBaseUrl: "https://managed.example.test/", - wsBaseUrl: "wss://managed.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - endpointRuntime: { - providerKind: "cloudflare_tunnel", - connectorToken: "connector-token", - }, - relayIssuer: "https://relay.example.test", - cloudUserId: "user_123", - environmentCredential: "environment-credential", - cloudMintPublicKey: "cloud-mint-public-key", - }; -} - -function validLinkChallengeResponse() { - return { - challenge: "link-challenge", - expiresAt: "2026-05-25T00:05:00.000Z", - }; -} - -function requestBodyText(body: BodyInit | null | undefined): string { - return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? ""); -} - -describe("mobile cloud link environment client", () => { - beforeEach(() => { - vi.restoreAllMocks(); - createProofMock.mockClear(); - }); - - it.effect( - "rejects relay link credentials for a different environment before persisting relay config", - () => - Effect.gen(function* () { - const fetchMock = vi.fn((url: string | URL) => { - if (String(url).endsWith("/v1/client/environment-link-challenges")) { - return Promise.resolve(Response.json(validLinkChallengeResponse())); - } - if (String(url).endsWith("/api/connect/link-proof")) { - return Promise.resolve(Response.json(validLinkProof())); - } - return Promise.resolve(Response.json(validLinkResponse("env-other"))); - }); - vi.stubGlobal("fetch", fetchMock); - - const error = yield* withCloudServices( - linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", - connection: savedConnection, - liveActivitiesEnabled: true, - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different environment.", - }); - expect(fetchMock).toHaveBeenCalledTimes(3); - }), - ); - - it.effect("rejects relay link credentials for a different managed endpoint provider", () => - Effect.gen(function* () { - const fetchMock = vi.fn((url: string | URL) => { - if (String(url).endsWith("/v1/client/environment-link-challenges")) { - return Promise.resolve(Response.json(validLinkChallengeResponse())); - } - if (String(url).endsWith("/api/connect/link-proof")) { - return Promise.resolve(Response.json(validLinkProof())); - } - return Promise.resolve( - Response.json({ - ...validLinkResponse(), - endpoint: { - ...validLinkResponse().endpoint, - providerKind: "manual", - }, - }), - ); - }); - vi.stubGlobal("fetch", fetchMock); - - const error = yield* withCloudServices( - linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", - connection: savedConnection, - liveActivitiesEnabled: true, - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different endpoint provider.", - }); - expect(fetchMock).toHaveBeenCalledTimes(3); - }), - ); - - it.effect("preserves disabled Live Activity preferences when linking an environment", () => - Effect.gen(function* () { - const bodies: Array = []; - const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { - if (init?.body) { - bodies.push(JSON.parse(requestBodyText(init.body))); - } - if (String(url).endsWith("/v1/client/environment-link-challenges")) { - return Promise.resolve(Response.json(validLinkChallengeResponse())); - } - if (String(url).endsWith("/api/connect/link-proof")) { - return Promise.resolve(Response.json(validLinkProof())); - } - if (String(url).endsWith("/v1/client/environment-links")) { - return Promise.resolve(Response.json(validLinkResponse())); - } - return Promise.resolve( - Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), - ); - }); - vi.stubGlobal("fetch", fetchMock); - - yield* withCloudServices( - linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", - connection: savedConnection, - liveActivitiesEnabled: false, - }), - ); - - expect(bodies[1]).toMatchObject({ - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - origin: { - localHttpHost: "127.0.0.1", - localHttpPort: 443, - }, - }); - expect(bodies[2]).toMatchObject({ - deviceId: "device-1", - notificationsEnabled: true, - liveActivitiesEnabled: false, - managedTunnelsEnabled: true, - }); - expect(bodies[3]).toMatchObject({ - cloudUserId: "user_123", - environmentCredential: "environment-credential", - }); - }), - ); -}); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts deleted file mode 100644 index f072f5c96d83d..0000000000000 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ /dev/null @@ -1,241 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/http"; -import { - EnvironmentCloudEndpointUnavailableError, - EnvironmentHttpBadRequestError, - EnvironmentHttpConflictError, - EnvironmentHttpForbiddenError, - EnvironmentHttpInternalServerError, - EnvironmentHttpUnauthorizedError, -} from "@t3tools/contracts"; -import { - type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType, - type RelayManagedEndpointProviderKind, -} from "@t3tools/contracts/relay"; -import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { ManagedRelay, relayRequestFailureDetail } from "@t3tools/client-runtime/relay"; -import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; - -import type { SavedRemoteConnection } from "../../lib/connection"; -import * as MobileStorage from "../../persistence/mobile-storage"; -import { resolveCloudPublicConfig } from "./publicConfig"; - -function readRelayUrl(): string | null { - return resolveCloudPublicConfig().relay.url; -} - -export class CloudEnvironmentLinkError extends Schema.TaggedError()( - "CloudEnvironmentLinkError", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - traceId: Schema.optional(Schema.String), - }, -) {} - -const isEnvironmentCloudApiError = Schema.is( - Schema.Union([ - EnvironmentHttpBadRequestError, - EnvironmentHttpUnauthorizedError, - EnvironmentHttpForbiddenError, - EnvironmentHttpConflictError, - EnvironmentHttpInternalServerError, - EnvironmentCloudEndpointUnavailableError, - ]), -); - -const MANAGED_ENDPOINT_PROVIDER_KIND = - "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; - -function cloudEnvironmentLinkError(message: string) { - return (cause: unknown) => { - const environmentError = findEnvironmentCloudApiError(cause); - const traceId = findErrorTraceId(cause); - const detail = environmentError - ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` - : withDevCause(message, cause); - return new CloudEnvironmentLinkError({ - message: detail, - cause, - ...(traceId === null ? {} : { traceId }), - }); - }; -} - -function isDevRuntime(): boolean { - return typeof __DEV__ !== "undefined" && __DEV__; -} - -function causeMessage(cause: unknown): string | null { - if (cause instanceof Error && cause.message) { - return cause.message; - } - if (typeof cause === "object" && cause !== null) { - const record = cause as { readonly message?: unknown; readonly cause?: unknown }; - if (typeof record.message === "string" && record.message.length > 0) { - const nested = causeMessage(record.cause); - return nested ? `${record.message}: ${nested}` : record.message; - } - } - return null; -} - -function withDevCause(message: string, cause: unknown): string { - if (!isDevRuntime()) { - return message; - } - const detail = causeMessage(cause); - return detail ? `${message} (${detail})` : message; -} - -function decodedRelayClientError(message: string) { - return (cause: ManagedRelay.ManagedRelayClientError) => { - const failed = cause._tag === "ManagedRelayRequestFailedError" ? cause : undefined; - const traceId = failed?.traceId; - const detail = failed ? relayRequestFailureDetail(failed) : null; - return new CloudEnvironmentLinkError({ - message: detail ? `${message}: ${detail}` : message, - cause, - ...(traceId ? { traceId } : {}), - }); - }; -} - -function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { - if (isEnvironmentCloudApiError(cause)) { - return cause; - } - if (typeof cause !== "object" || cause === null) { - return null; - } - return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null; -} - -function requireRelayUrl(): Effect.Effect { - const relayUrl = readRelayUrl(); - return relayUrl - ? Effect.succeed(relayUrl) - : Effect.fail(new CloudEnvironmentLinkError({ message: "Relay URL is not configured." })); -} - -function endpointOrigin(httpBaseUrl: string) { - const url = new URL(httpBaseUrl); - return { - localHttpHost: "127.0.0.1", - localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)), - }; -} - -function ensureLinkedEnvironmentMatches(input: { - readonly expectedEnvironmentId: string; - readonly expectedProviderKind: RelayManagedEndpointProviderKind; - readonly link: RelayEnvironmentLinkResponseType; -}): Effect.Effect { - if (input.link.environmentId !== input.expectedEnvironmentId) { - return new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different environment.", - }); - } - if (input.link.endpoint.providerKind !== input.expectedProviderKind) { - return new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different endpoint provider.", - }); - } - return Effect.void; -} - -interface LinkEnvironmentToCloudInput { - readonly connection: SavedRemoteConnection; - readonly clerkToken: string; -} - -type LinkEnvironmentToCloudRequirements = - | HttpClient.HttpClient - | ManagedRelay.ManagedRelayClient - | MobileStorage.MobileStorage; - -export const linkEnvironmentToCloudWithPreference = Effect.fn( - "linkEnvironment.linkEnvironmentToCloudWithPreference", -)(function* ( - input: LinkEnvironmentToCloudInput & { readonly liveActivitiesEnabled: boolean }, -): Effect.fn.Return { - if (!input.connection.bearerToken) { - return yield* new CloudEnvironmentLinkError({ - message: "Only a locally paired bearer connection can be linked to the cloud.", - }); - } - const localBearerToken = input.connection.bearerToken; - const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelay.ManagedRelayClient; - const storage = yield* MobileStorage.MobileStorage; - const deviceId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( - Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), - ); - const liveActivitiesEnabled = input.liveActivitiesEnabled; - const challenge = yield* relayClient - .createEnvironmentLinkChallenge({ - clerkToken: input.clerkToken, - payload: { - notificationsEnabled: true, - liveActivitiesEnabled, - managedTunnelsEnabled: true, - }, - }) - .pipe( - Effect.mapError( - decodedRelayClientError(`${relayUrl}/v1/client/environment-link-challenges failed`), - ), - ); - const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl); - const proof = yield* environmentClient.connect - .linkProof({ - headers: { authorization: `Bearer ${localBearerToken}` }, - payload: { - challenge: challenge.challenge, - relayIssuer: relayUrl, - endpoint: { - httpBaseUrl: input.connection.httpBaseUrl, - wsBaseUrl: input.connection.wsBaseUrl, - providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, - }, - origin: endpointOrigin(input.connection.httpBaseUrl), - }, - }) - .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not obtain environment link proof."))); - const link = yield* relayClient - .linkEnvironment({ - clerkToken: input.clerkToken, - payload: { - deviceId, - proof, - notificationsEnabled: true, - liveActivitiesEnabled, - managedTunnelsEnabled: true, - }, - }) - .pipe( - Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/client/environment-links failed`)), - ); - yield* ensureLinkedEnvironmentMatches({ - expectedEnvironmentId: input.connection.environmentId, - expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, - link, - }); - - yield* environmentClient.connect - .relayConfig({ - headers: { authorization: `Bearer ${localBearerToken}` }, - payload: { - relayUrl, - relayIssuer: link.relayIssuer, - cloudUserId: link.cloudUserId, - environmentCredential: link.environmentCredential, - cloudMintPublicKey: link.cloudMintPublicKey, - endpointRuntime: link.endpointRuntime, - }, - }) - .pipe( - Effect.mapError(cloudEnvironmentLinkError("Could not configure environment relay access.")), - ); -}); diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts index 3b34f6fb2d664..919732e1a7650 100644 --- a/apps/mobile/src/features/cloud/managedRelayState.ts +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -60,10 +60,3 @@ export function useManagedRelayEnvironments() { refresh, }; } - -export function refreshManagedRelayEnvironments(): void { - const session = appAtomRegistry.get(managedRelaySessionAtom); - if (session) { - managedRelayQueryManager.refreshEnvironments(appAtomRegistry, session.accountId); - } -} diff --git a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx index cabe229f9a262..3c9ba909b96cb 100644 --- a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx @@ -1,12 +1,4 @@ import * as Dialog from "../../components/ui/Dialog"; -import { - AuthRelayWriteScope, - managedCapabilityDenial, - type EnvironmentId, -} from "@t3tools/contracts"; -import { readEnvironmentScope } from "../../state/session"; -import { appAtomRegistry } from "../../state/atom-registry"; -import { serverEnvironment } from "../../state/server"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { useAuth } from "@clerk/expo"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; @@ -31,41 +23,25 @@ import { openAndroidLiveUpdateSettings, supportsAndroidLiveUpdateSettings, } from "../agent-awareness/androidNotifications"; -import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; import { getAgentAwarenessRegistrationStatus, refreshAgentAwarenessRegistration, subscribeAgentAwarenessRegistrationStatus, + updateAgentAwarenessRegistrationPreferences, } from "../agent-awareness/remoteRegistration"; -import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; -import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; +import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { makePermissionStatusAtom } from "../../lib/permissionStatusAtom"; import { runtime } from "../../lib/runtime"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; -import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { SettingsScreen } from "./components/SettingsScreen"; import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; -/** - * A policy that turns Connect off refuses relay links and preferences, which would fail the - * whole batch, so those environments are skipped like ones this session cannot configure. - */ -function canLinkEnvironmentToConnect(environmentId: EnvironmentId) { - const policy = appAtomRegistry.get( - serverEnvironment.configValueAtom(environmentId), - )?.managedPolicy; - return ( - readEnvironmentScope(environmentId, AuthRelayWriteScope) && - managedCapabilityDenial(policy, "connect") === undefined - ); -} - type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; -type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; +type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "updating"; const notificationStatusAtom = makePermissionStatusAtom({ label: "mobile:notification-permission", @@ -138,8 +114,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { : agentAwarenessPlatform.subtitle; const insets = useSafeAreaInsets(); const navigation = useNavigation(); - const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - const { savedConnectionsById } = useSavedRemoteConnections(); + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const notificationStatus = useAtomValue(notificationStatusAtom); const setNotificationStatus = useAtomSet(notificationStatusAtom); // A write in progress or a failed one shows its own status until the auth or @@ -160,16 +135,10 @@ function ConfiguredSettingsNotificationsRouteScreen() { setLiveActivityOverride({ status, basis: liveActivityBasis }); const liveActivityWriteInFlight = useRef(false); const deviceRegistered = useDeviceRegistered(); - const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value.liveActivitiesEnabled !== false - : true; const canClearLiveActivitiesPreference = AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.liveActivitiesEnabled !== false; - const connections = Object.values(savedConnectionsById); - const environmentCount = connections.length; - const requestNotifications = async () => { const result = await settleAsyncResult(() => runtime.runPromiseExit( @@ -239,13 +208,13 @@ function ConfiguredSettingsNotificationsRouteScreen() { ); }; - const linkEnvironments = async () => { + const enableLiveActivities = async () => { if (!isSignedIn) { promptSignIn(); return; } - setLiveActivityStatus("linking"); + setLiveActivityStatus("updating"); if (Platform.OS === "android") { const permission = await settleAsyncResult(() => runtime.runPromiseExit(requestAgentNotificationPermission), @@ -273,31 +242,9 @@ function ConfiguredSettingsNotificationsRouteScreen() { } setNotificationStatus("enabled"); } - const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); - if (tokenResult._tag === "Failure") { - setLiveActivityStatus("disabled"); - const error = squashAtomCommandFailure(tokenResult); - Dialog.alert( - Platform.OS === "android" ? "Ongoing activity unavailable" : "Live Activities unavailable", - error instanceof Error ? error.message : "Could not enable agent activity updates.", - ); - return; - } - if (!tokenResult.value) { - promptSignIn(); - setLiveActivityStatus("signed-out"); - return; - } - const updateResult = await settleAsyncResult(() => runtime.runPromiseExit( - setLiveActivityUpdatesEnabled({ - canConfigureEnvironment: canLinkEnvironmentToConnect, - enabled: true, - previousEnabled: liveActivitiesPreferenceEnabled, - clerkToken: tokenResult.value, - connections, - }), + updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }), ), ); if (updateResult._tag === "Failure") { @@ -315,15 +262,11 @@ function ConfiguredSettingsNotificationsRouteScreen() { } savePreferences({ liveActivitiesEnabled: true }); - refreshManagedRelayEnvironments(); setLiveActivityStatus("enabled"); - // Environment linking can precede device registration; only registered devices can receive Live Activities. if (getAgentAwarenessRegistrationStatus() === "registered") { Dialog.alert( Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled", - environmentCount > 0 - ? "Agent activity updates are enabled for environments this connection can configure." - : "Agent activity updates are enabled. Add an environment to start receiving updates.", + "Agent activity updates are enabled for this device.", ); } else { Dialog.alert( @@ -354,28 +297,9 @@ function ConfiguredSettingsNotificationsRouteScreen() { }; async function disableLiveActivities() { - let token: string | null = null; - if (isSignedIn) { - const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); - if (tokenResult._tag === "Failure") { - setLiveActivityStatus("enabled"); - reportAtomCommandResult(tokenResult, { - label: "live activity disable token lookup", - }); - return; - } - token = tokenResult.value; - } - const updateResult = await settleAsyncResult(() => runtime.runPromiseExit( - setLiveActivityUpdatesEnabled({ - canConfigureEnvironment: canLinkEnvironmentToConnect, - enabled: false, - previousEnabled: liveActivitiesPreferenceEnabled, - clerkToken: token, - connections, - }), + updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: false }), ), ); if (updateResult._tag === "Failure") { @@ -386,7 +310,6 @@ function ConfiguredSettingsNotificationsRouteScreen() { return; } savePreferences({ liveActivitiesEnabled: false }); - refreshManagedRelayEnvironments(); setLiveActivityStatus("disabled"); } @@ -395,7 +318,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { if (liveActivityWriteInFlight.current) return; if (!enabled) { liveActivityWriteInFlight.current = true; - setLiveActivityStatus("linking"); + setLiveActivityStatus("updating"); await disableLiveActivities(); liveActivityWriteInFlight.current = false; return; @@ -407,7 +330,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { } liveActivityWriteInFlight.current = true; - await linkEnvironments(); + await enableLiveActivities(); liveActivityWriteInFlight.current = false; }; @@ -444,7 +367,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { !agentAwarenessPushAvailable || !isLoaded || liveActivityStatus === "checking" || - liveActivityStatus === "linking" + liveActivityStatus === "updating" } icon="bolt.circle" label={ @@ -457,7 +380,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { subtitle={agentAwarenessSubtitle} value={ agentAwarenessPushAvailable && - (liveActivityStatus === "enabled" || liveActivityStatus === "linking") && + (liveActivityStatus === "enabled" || liveActivityStatus === "updating") && deviceRegistered } onValueChange={(enabled) => void handleLiveActivitiesChange(enabled)}