From 799f229f48012431138f1752af092d99e21074e0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 8 Nov 2026 03:01:38 +0100 Subject: [PATCH] fix(mobile): live activity toggle no longer re-links saved environments Turning Live Activity updates (Android ongoing activity) on or off re-linked every saved environment through the host's link-proof endpoint, which only accepts loopback hosts. A real phone reaches the host over LAN, Tailscale or the tunnel, so the toggle always failed with "Invalid managed endpoint origin." The relay already gates Live Activity delivery on each device's registration preference, and desktop, web and CLI links enable activities at the link level. The toggle now only updates this device's registration preference. The unused mobile environment-link module and its Clerk token plumbing are gone. Android registrations restore the stored native activity configuration when the permission lookup, preference update, or relay write fails, unless a newer registration has configured the device since. Fixes pingdotgg/t3code#27412 Rebased from pingdotgg/t3code#27432 onto current main. --- .../liveActivityPreferences.test.ts | 161 ----------- .../liveActivityPreferences.ts | 80 ------ .../remoteRegistration.test.ts | 122 ++++++++ .../agent-awareness/remoteRegistration.ts | 37 ++- .../features/cloud/linkEnvironment.test.ts | 268 ------------------ .../src/features/cloud/linkEnvironment.ts | 241 ---------------- .../src/features/cloud/managedRelayState.ts | 7 - .../SettingsNotificationsRouteScreen.tsx | 103 +------ 8 files changed, 163 insertions(+), 856 deletions(-) delete mode 100644 apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts delete mode 100644 apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts delete mode 100644 apps/mobile/src/features/cloud/linkEnvironment.test.ts delete mode 100644 apps/mobile/src/features/cloud/linkEnvironment.ts 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 e9f5a3b1e5394..169b5e08a4990 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -665,6 +665,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 437a00be9eaf1..ff3150d2ffd27 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -121,6 +121,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; @@ -659,14 +660,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() @@ -690,7 +709,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)}