diff --git a/apps/mobile/app/settings.tsx b/apps/mobile/app/settings.tsx index e834d1d1a57..2313c1b381e 100644 --- a/apps/mobile/app/settings.tsx +++ b/apps/mobile/app/settings.tsx @@ -101,6 +101,7 @@ import { runManualUpdateCheck, type ManualUpdateCheckOutcome, } from '@/update/manualUpdateCheck'; +import { runSelfHostedOtaRequest } from '@/update/otaRequestCoordinator'; import { useBundleUpdatePrompt } from '@/update/useBundleUpdatePrompt'; import { useUpdateChannelGate } from '@/update/useUpdateChannelGate'; import { useBetaChannel } from '@/update/useBetaChannel'; @@ -344,10 +345,16 @@ export default function SettingsScreen() { const outcome = await runManualUpdateCheck({ checkBundleUpdate: bundleCheckEnabled ? checkBundleUpdate : undefined, otaEnabled: updatesEnabled, - // OTA 检查会携带 eas-client-id,须经隐私同意闸门(企业 SSO 豁免协议门,可能未 - // 同意;且检查进行中登出会撤销同意)。整包 /latest 为匿名请求,不在此列。动态 - // 判定而非调用瞬间快照,manifest 请求前与资源下载前各问一次。 - isConsented: hasPrivacyConsent, + // 自建线由事务协调器覆盖共享 UUID,因此不再借用 analytics consent;EAS / + // TestFlight 仍保留原同意闸门,TapDB 的 consent 状态也完全不在这里修改。 + ...(IS_OTA_SELFHOST + ? { + withOtaClient: (operation) => runSelfHostedOtaRequest( + updateChannel.channel, + operation, + ), + } + : { isConsented: hasPrivacyConsent }), checkOtaUpdate: () => Updates.checkForUpdateAsync(), fetchOtaUpdate: () => Updates.fetchUpdateAsync(), reload: () => Updates.reloadAsync(), @@ -379,6 +386,7 @@ export default function SettingsScreen() { checkBundleUpdate, currentlyRunning.isEmergencyLaunch, t, + updateChannel.channel, updateCheckEnabled, updatesEnabled, ]); diff --git a/apps/mobile/src/__tests__/mobileSettings.test.ts b/apps/mobile/src/__tests__/mobileSettings.test.ts index 0bc84654a85..9b340d8793d 100644 --- a/apps/mobile/src/__tests__/mobileSettings.test.ts +++ b/apps/mobile/src/__tests__/mobileSettings.test.ts @@ -304,6 +304,9 @@ describe('mobile settings overview', () => { expect(source).not.toContain('settings.checkBundleUpdateButton'); expect(source).not.toContain('testID="settings.bundleUpdate"'); expect(source).toContain('runManualUpdateCheck({'); + expect(source).toContain('...(IS_OTA_SELFHOST'); + expect(source).toContain('withOtaClient: (operation) => runSelfHostedOtaRequest('); + expect(source).toContain(': { isConsented: hasPrivacyConsent })'); expect(source).toContain('isTestFlightBuild: IS_TESTFLIGHT_BUILD'); expect(source).toContain('const updateCheckEnabled = bundleCheckEnabled || updatesEnabled'); expect(source).toContain('checkBundleUpdate: bundleCheckEnabled ? checkBundleUpdate : undefined'); diff --git a/apps/mobile/src/__tests__/nativeAppConfig.test.ts b/apps/mobile/src/__tests__/nativeAppConfig.test.ts index 59bf9c77c20..4ec08bd38b7 100644 --- a/apps/mobile/src/__tests__/nativeAppConfig.test.ts +++ b/apps/mobile/src/__tests__/nativeAppConfig.test.ts @@ -217,6 +217,8 @@ describe('mobile native app config', () => { checkAutomatically: 'NEVER', disableAntiBrickingMeasures: true, }); + // 共享 EAS-Client-ID 只能由 JS 事务式覆盖;写入原生 requestHeaders 会改变 fingerprint。 + expect(selfHosted.updates).not.toHaveProperty('requestHeaders'); expect(JSON.stringify(selfHosted)).not.toContain('must-not-be-baked.example.com'); // 自建 app 身份按 region 从 self-host-regions.json(.example 回落)取,而非写死。 expect(selfHosted.ios.bundleIdentifier).toBe('com.xd.cindycn'); diff --git a/apps/mobile/src/update/manualUpdateCheck.test.ts b/apps/mobile/src/update/manualUpdateCheck.test.ts index 3652ac27b5e..266293426da 100644 --- a/apps/mobile/src/update/manualUpdateCheck.test.ts +++ b/apps/mobile/src/update/manualUpdateCheck.test.ts @@ -112,6 +112,27 @@ describe('runManualUpdateCheck', () => { expect(input.fetchOtaUpdate).not.toHaveBeenCalled(); }); + it('自建线用同一个包装器覆盖完整 check → fetch → reload 事务', async () => { + const wrappedCheck = vi.fn(async () => ({ isAvailable: true })); + const wrappedFetch = vi.fn(async () => ({ isNew: true })); + const wrappedReload = vi.fn(async () => undefined); + const withOtaClient: NonNullable = + async (operation) => operation({ + checkForUpdateAsync: wrappedCheck, + fetchUpdateAsync: wrappedFetch, + reloadAsync: wrappedReload, + }); + const input = deps({ withOtaClient }); + + await expect(runManualUpdateCheck(input)).resolves.toEqual({ kind: 'reloading' }); + expect(wrappedCheck).toHaveBeenCalledOnce(); + expect(wrappedFetch).toHaveBeenCalledOnce(); + expect(wrappedReload).toHaveBeenCalledOnce(); + expect(input.checkOtaUpdate).not.toHaveBeenCalled(); + expect(input.fetchOtaUpdate).not.toHaveBeenCalled(); + expect(input.reload).not.toHaveBeenCalled(); + }); + // emergency launch(没有 launchedUpdate)时 reloadAsync 会被原生层拒绝,但 bundle 已落盘: // 这不是一次失败的检查,必须导向"重开 App 生效",否则用户只看到一条无从下手的红字报错。 it('asks for a manual restart when an emergency launch blocks reloading the downloaded bundle', async () => { @@ -129,7 +150,7 @@ describe('runManualUpdateCheck', () => { expect(input.reload).toHaveBeenCalledOnce(); }); - it('still reports a failure when reload fails without any downloaded bundle', async () => { + it('does not reload when fetch reports that no new bundle was downloaded', async () => { const input = deps({ checkOtaUpdate: vi.fn(async () => ({ isAvailable: true })), fetchOtaUpdate: vi.fn(async () => ({ isNew: false })), @@ -139,11 +160,8 @@ describe('runManualUpdateCheck', () => { isEmergencyLaunch: vi.fn(() => true), }); - await expect(runManualUpdateCheck(input)).resolves.toEqual({ - kind: 'error', - reason: 'ota-check', - detail: 'reload rejected', - }); + await expect(runManualUpdateCheck(input)).resolves.toEqual({ kind: 'up-to-date' }); + expect(input.reload).not.toHaveBeenCalled(); }); // 非应急启动下的 reload 失败原因未知,原始详情是唯一线索:不能被重启指引盖掉。 diff --git a/apps/mobile/src/update/manualUpdateCheck.ts b/apps/mobile/src/update/manualUpdateCheck.ts index 519c1ca4f71..1049a50ab9a 100644 --- a/apps/mobile/src/update/manualUpdateCheck.ts +++ b/apps/mobile/src/update/manualUpdateCheck.ts @@ -22,17 +22,24 @@ export type ManualUpdateCheckOutcome = | { kind: 'busy' } | { kind: 'error'; reason: 'bundle-check' | 'ota-check'; detail?: string }; +export interface ManualOtaClient { + checkForUpdateAsync: () => Promise<{ isAvailable: boolean }>; + fetchUpdateAsync: () => Promise<{ isNew: boolean }>; + reloadAsync: () => Promise; +} + /** 统一更新检查所需的外部能力,由设置页注入真实 Expo / 整包更新实现。 */ export interface ManualUpdateCheckDeps { /** 自建线传入整包检查;EAS 线省略后直接检查 OTA。 */ checkBundleUpdate?: () => Promise; otaEnabled: boolean; /** - * 隐私同意闸门(动态判定,非调用瞬间快照):manifest 请求前与资源下载前分别重查。 - * 用户点击「检查更新」后、请求尚未完成时登出撤销同意,这里必须停止继续携带 - * eas-client-id 的请求。缺省(未提供)视为不启用该闸门。 + * 非自建 EAS / TestFlight 的隐私同意闸门(动态判定,非调用瞬间快照):manifest + * 请求前与资源下载前分别重查。自建 OTA 不传,由 withOtaClient 覆盖共享 UUID。 */ isConsented?: () => boolean; + /** 自建 OTA 用它包住完整 check → fetch → reload 事务;EAS/TestFlight 不传。 */ + withOtaClient?: (operation: (client: ManualOtaClient) => Promise) => Promise; checkOtaUpdate: () => Promise<{ isAvailable: boolean }>; /** isNew 表示确实落盘了一个新 bundle(reload 失败时用它区分"已下载待重启"与"什么都没拿到")。 */ fetchOtaUpdate: () => Promise<{ isNew: boolean }>; @@ -53,6 +60,7 @@ export async function runManualUpdateCheck({ checkBundleUpdate, otaEnabled, isConsented, + withOtaClient, checkOtaUpdate, fetchOtaUpdate, reload, @@ -74,22 +82,32 @@ export async function runManualUpdateCheck({ } if (!otaEnabled) return { kind: 'ota-unavailable' }; - // 整包检查是匿名请求,不受同意门约束;只有 OTA manifest/资源会携带 eas-client-id, - // 在发起 manifest 请求前先问一次同意(处理「点击检查时未同意」的快照与实况不一致)。 + // 整包检查是匿名请求,不受同意门约束;非自建 OTA 在发起 manifest 前先问一次同意。 if (isConsented && !isConsented()) return { kind: 'ota-unavailable' }; let fetchedNewBundle = false; try { - const ota = await checkOtaUpdate(); - if (!ota.isAvailable) return { kind: 'up-to-date' }; - // manifest 请求期间用户可能登出撤销同意:下载资源前再问一次,不得在撤销后继续 - // 拉取带标识的 bundle。 - if (isConsented && !isConsented()) return { kind: 'ota-unavailable' }; - onPhase('downloading'); - const fetched = await fetchOtaUpdate(); - fetchedNewBundle = fetched.isNew; - await reload(); - return { kind: 'reloading' }; + const operation = async (client: ManualOtaClient): Promise => { + const ota = await client.checkForUpdateAsync(); + if (!ota.isAvailable) return { kind: 'up-to-date' }; + // manifest 请求期间用户可能登出撤销同意:下载前再问一次,避免非自建 EAS + // 在撤销后继续携带它自己的安装标识。自建线不传本闸门,统一覆盖共享 UUID。 + if (isConsented && !isConsented()) return { kind: 'ota-unavailable' }; + onPhase('downloading'); + const fetched = await client.fetchUpdateAsync(); + fetchedNewBundle = fetched.isNew; + if (!fetched.isNew) return { kind: 'up-to-date' }; + await client.reloadAsync(); + return { kind: 'reloading' }; + }; + const client: ManualOtaClient = { + checkForUpdateAsync: checkOtaUpdate, + fetchUpdateAsync: fetchOtaUpdate, + reloadAsync: reload, + }; + return withOtaClient + ? await withOtaClient(operation) + : await operation(client); } catch (error) { // emergency launch(没有 launchedUpdate)下 reload 必被原生层拒绝,而 bundle 已经落盘、 // 下次冷启动就会生效:这不是一次失败的检查,报"检查更新失败"只会让用户无从下手。 diff --git a/apps/mobile/src/update/otaRequestCoordinator.test.ts b/apps/mobile/src/update/otaRequestCoordinator.test.ts new file mode 100644 index 00000000000..750e38f6f90 --- /dev/null +++ b/apps/mobile/src/update/otaRequestCoordinator.test.ts @@ -0,0 +1,498 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { asyncStorage, nativeState, nativeUpdates } = vi.hoisted(() => ({ + asyncStorage: new Map(), + nativeState: { + updateId: 'native-update' as string | null, + runtimeVersion: 'runtime-1' as string | null, + isEmergencyLaunch: false, + }, + nativeUpdates: { + checkForUpdateAsync: vi.fn(), + fetchUpdateAsync: vi.fn(), + reloadAsync: vi.fn(), + setUpdateURLAndRequestHeadersOverride: vi.fn(), + }, +})); + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => asyncStorage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { asyncStorage.set(key, value); }), + removeItem: vi.fn(async (key: string) => { asyncStorage.delete(key); }), + }, +})); +vi.mock('expo-updates', () => ({ + get updateId() { return nativeState.updateId; }, + get runtimeVersion() { return nativeState.runtimeVersion; }, + get isEmergencyLaunch() { return nativeState.isEmergencyLaunch; }, + ...nativeUpdates, +})); +vi.mock('@/config/env', () => ({ OTA_SERVER_BASE_URL: 'https://updates.example.test' })); + +import type { UpdateChannel } from '@cindy/maker-shared/update-channel'; +import { + __testing as analyticsConsentTesting, + getAnalyticsConsentState, + hydrateAnalyticsConsent, +} from '@/analytics/analyticsConsentStore'; +import { + __testing as canaryChannelTesting, + hydrateCanaryChannel, + resolveUpdateChannelForDevice, +} from './canaryChannelStore'; +import { + EAS_CLIENT_ID_HEADER, + SHARED_OTA_CLIENT_ID, + createOtaRequestCoordinator, + runSelfHostedOtaRequest, + sharedOtaRequestHeaders, + type OtaRequestClient, +} from './otaRequestCoordinator'; + +const UPDATE_URL = 'https://updates.example.test/manifest'; + +beforeEach(async () => { + asyncStorage.clear(); + nativeState.updateId = 'native-update'; + nativeState.runtimeVersion = 'runtime-1'; + nativeState.isEmergencyLaunch = false; + await Promise.all([ + analyticsConsentTesting.resetMemory(), + canaryChannelTesting.resetMemory(), + ]); + vi.clearAllMocks(); +}); + +function createHarness(options: { + stored?: string | null; + currentUpdateId?: string | null; + currentRuntimeVersion?: string | null; + checkResult?: Awaited>; + fetchResult?: Awaited>; +} = {}) { + let stored = options.stored ?? null; + const configs: Array<{ updateUrl: string; requestHeaders: Record }> = []; + const events: string[] = []; + const client: OtaRequestClient = { + checkForUpdateAsync: vi.fn(async () => { + events.push('check'); + return options.checkResult ?? { isAvailable: false }; + }), + fetchUpdateAsync: vi.fn(async () => { + events.push('fetch'); + return options.fetchResult ?? { isNew: false }; + }), + reloadAsync: vi.fn(async () => { + events.push('reload'); + }), + }; + const readBaseline = vi.fn(async () => stored); + const writeBaseline = vi.fn(async (raw: string) => { + events.push(`write:${JSON.parse(raw).mode}`); + stored = raw; + }); + const setConfigOverride = vi.fn((config: { updateUrl: string; requestHeaders: Record }) => { + configs.push(config); + events.push(config.requestHeaders[EAS_CLIENT_ID_HEADER] ? 'config:shared' : 'config:legacy'); + }); + const coordinator = createOtaRequestCoordinator({ + readBaseline, + writeBaseline, + setConfigOverride, + client, + }); + const run = ( + operation: (coordinatedClient: OtaRequestClient) => Promise, + channel: UpdateChannel = 'canary', + timeouts: { checkTimeoutMs?: number; fetchTimeoutMs?: number } = {}, + ) => + coordinator.run({ + updateUrl: UPDATE_URL, + channel, + currentUpdateId: options.currentUpdateId === undefined ? 'u1' : options.currentUpdateId, + currentRuntimeVersion: options.currentRuntimeVersion === undefined + ? 'runtime-1' + : options.currentRuntimeVersion, + ...timeouts, + }, operation); + + return { + client, + configs, + events, + getStored: () => stored, + readBaseline, + run, + setConfigOverride, + writeBaseline, + }; +} + +describe('sharedOtaRequestHeaders', () => { + it('所有自建通道都用同一个共享 UUID,并保留 Canary/Beta 路由头', () => { + expect(sharedOtaRequestHeaders('release')).toEqual({ + [EAS_CLIENT_ID_HEADER]: SHARED_OTA_CLIENT_ID, + }); + expect(sharedOtaRequestHeaders('canary')).toEqual({ + 'x-cindy-update-channel': 'canary', + [EAS_CLIENT_ID_HEADER]: SHARED_OTA_CLIENT_ID, + }); + expect(sharedOtaRequestHeaders('beta')).toEqual({ + 'x-cindy-update-channel': 'beta', + [EAS_CLIENT_ID_HEADER]: SHARED_OTA_CLIENT_ID, + }); + }); +}); + +describe('createOtaRequestCoordinator', () => { + it('U1 无 U2 时临时使用共享 UUID,并在结束前恢复 U1 的旧请求头', async () => { + const h = createHarness(); + + await h.run((client) => client.checkForUpdateAsync()); + + expect(h.configs).toEqual([ + { + updateUrl: UPDATE_URL, + requestHeaders: { + 'x-cindy-update-channel': 'canary', + [EAS_CLIENT_ID_HEADER]: SHARED_OTA_CLIENT_ID, + }, + }, + { + updateUrl: UPDATE_URL, + requestHeaders: { 'x-cindy-update-channel': 'canary' }, + }, + ]); + expect(JSON.parse(h.getStored()!)).toEqual({ + version: 1, + mode: 'legacy', + updateId: 'u1', + runtimeVersion: 'runtime-1', + updateUrl: UPDATE_URL, + channel: 'canary', + }); + }); + + it('共享 header 下服务端仍返回 U1 时按无更新收口,不尝试重下载同一 ID', async () => { + const h = createHarness({ + checkResult: { isAvailable: true, manifest: { id: 'U1' } }, + }); + + const result = await h.run(async (client) => { + const checked = await client.checkForUpdateAsync(); + if (checked.isAvailable) await client.fetchUpdateAsync(); + return checked; + }); + + expect(result).toMatchObject({ isAvailable: false, manifest: undefined }); + expect(h.client.fetchUpdateAsync).not.toHaveBeenCalled(); + expect(h.configs.at(-1)?.requestHeaders).toEqual({ 'x-cindy-update-channel': 'canary' }); + }); + + it('SSO + consent=false + Canary 可用共享 UUID 下载不同 ID 的 U2 并提交新基线', async () => { + asyncStorage.set(analyticsConsentTesting.storageKey, JSON.stringify({ consent: false })); + asyncStorage.set(canaryChannelTesting.storageKey, 'true'); + await Promise.all([hydrateAnalyticsConsent(), hydrateCanaryChannel()]); + const channel = resolveUpdateChannelForDevice(); + const h = createHarness({ + checkResult: { isAvailable: true, manifest: { id: 'u2' } }, + fetchResult: { isNew: true, manifest: { id: 'U2' } }, + }); + + const result = await h.run(async (client) => { + const checked = await client.checkForUpdateAsync(); + if (!checked.isAvailable) return 'up-to-date'; + const fetched = await client.fetchUpdateAsync(); + return fetched.isNew ? 'fetched' : 'up-to-date'; + }, channel); + + expect(getAnalyticsConsentState().consent).toBe(false); + expect(asyncStorage.get(analyticsConsentTesting.storageKey)).toBe('{"consent":false}'); + expect(channel).toBe('canary'); + expect(result).toBe('fetched'); + expect(JSON.parse(h.getStored()!)).toEqual({ + version: 1, + mode: 'shared', + updateId: 'u2', + runtimeVersion: 'runtime-1', + updateUrl: UPDATE_URL, + channel: 'canary', + }); + expect(h.configs.at(-1)?.requestHeaders).toEqual({ + 'x-cindy-update-channel': 'canary', + [EAS_CLIENT_ID_HEADER]: SHARED_OTA_CLIENT_ID, + }); + expect(h.events.indexOf('write:shared')).toBeGreaterThan(h.events.indexOf('fetch')); + expect(h.events.at(-1)).toBe('write:shared'); + }); + + it('U1 下载 U2 后跨协调器重建,U2 以共享请求头成为稳定基线', async () => { + const first = createHarness({ + checkResult: { isAvailable: true, manifest: { id: 'u2' } }, + fetchResult: { isNew: true, manifest: { id: 'u2' } }, + }); + await first.run(async (client) => { + await client.checkForUpdateAsync(); + await client.fetchUpdateAsync(); + }); + + // 新 coordinator 模拟 JS reload / 冷启动:只共享真实持久层,不复用内存 baseline。 + const h = createHarness({ + currentUpdateId: 'u2', + stored: first.getStored(), + }); + + await h.run((client) => client.checkForUpdateAsync()); + + expect(h.writeBaseline).not.toHaveBeenCalled(); + expect(h.configs).toHaveLength(1); + expect(h.configs.every((config) => ( + config.requestHeaders[EAS_CLIENT_ID_HEADER] === SHARED_OTA_CLIENT_ID + ))).toBe(true); + }); + + it('目标通道变化但没有新 bundle 时,仍恢复当前 U1 下载时的通道', async () => { + const h = createHarness({ + stored: JSON.stringify({ + version: 1, + mode: 'legacy', + updateId: 'u1', + runtimeVersion: 'runtime-1', + updateUrl: UPDATE_URL, + channel: 'canary', + }), + }); + + await h.run((client) => client.checkForUpdateAsync(), 'beta'); + + expect(h.configs[0]?.requestHeaders).toEqual({ + 'x-cindy-update-channel': 'beta', + [EAS_CLIENT_ID_HEADER]: SHARED_OTA_CLIENT_ID, + }); + expect(h.configs.at(-1)?.requestHeaders).toEqual({ 'x-cindy-update-channel': 'canary' }); + }); + + it('更新服务地址变化但没有新 bundle 时,仍恢复 U1 下载时的地址', async () => { + const previousUrl = 'https://old-updates.example.test/manifest'; + const h = createHarness({ + stored: JSON.stringify({ + version: 1, + mode: 'legacy', + updateId: 'u1', + runtimeVersion: 'runtime-1', + updateUrl: previousUrl, + channel: 'canary', + }), + }); + + await h.run((client) => client.checkForUpdateAsync()); + + expect(h.configs[0]?.updateUrl).toBe(UPDATE_URL); + expect(h.configs.at(-1)).toEqual({ + updateUrl: previousUrl, + requestHeaders: { 'x-cindy-update-channel': 'canary' }, + }); + }); + + it('runtime 变化时丢弃旧 runtime 的 pending marker', async () => { + const h = createHarness({ + currentUpdateId: 'new-embedded', + currentRuntimeVersion: 'runtime-2', + stored: JSON.stringify({ + version: 1, + mode: 'shared', + updateId: 'old-u2', + runtimeVersion: 'runtime-1', + updateUrl: UPDATE_URL, + channel: 'canary', + }), + }); + + await h.run((client) => client.checkForUpdateAsync(), 'release'); + + expect(JSON.parse(h.getStored()!)).toEqual({ + version: 1, + mode: 'legacy', + updateId: 'new-embedded', + runtimeVersion: 'runtime-2', + updateUrl: UPDATE_URL, + channel: 'release', + }); + expect(h.configs.at(-1)?.requestHeaders).toEqual({}); + }); + + it('check/fetch 失败时恢复旧基线,永不把临时共享配置留到下次启动', async () => { + const h = createHarness(); + vi.mocked(h.client.checkForUpdateAsync).mockRejectedValueOnce(new Error('offline')); + + await expect(h.run((client) => client.checkForUpdateAsync())).rejects.toThrow('offline'); + + expect(h.configs.at(-1)?.requestHeaders).toEqual({ 'x-cindy-update-channel': 'canary' }); + }); + + it('U2 marker 写入失败时中止迁移并恢复旧基线', async () => { + const h = createHarness({ + stored: JSON.stringify({ + version: 1, + mode: 'legacy', + updateId: 'u1', + runtimeVersion: 'runtime-1', + updateUrl: UPDATE_URL, + channel: 'canary', + }), + checkResult: { isAvailable: true, manifest: { id: 'u2' } }, + fetchResult: { isNew: true, manifest: { id: 'u2' } }, + }); + // 已有 legacy 基线,下一次写就是提交 shared 基线。 + h.writeBaseline.mockRejectedValueOnce(new Error('storage unavailable')); + + await expect(h.run(async (client) => { + await client.checkForUpdateAsync(); + await client.fetchUpdateAsync(); + })).rejects.toThrow('storage unavailable'); + + expect(h.configs.at(-1)?.requestHeaders).toEqual({ 'x-cindy-update-channel': 'canary' }); + expect(JSON.parse(h.getStored()!)).toMatchObject({ mode: 'legacy', updateId: 'u1' }); + }); + + it('fetch 超时先恢复 U1,原生结果晚到时仍在队列锁内提交 U2', async () => { + const h = createHarness(); + let finishNativeFetch!: (result: { isNew: boolean; manifest: { id: string } }) => void; + vi.mocked(h.client.fetchUpdateAsync).mockImplementationOnce(() => new Promise((resolve) => { + finishNativeFetch = resolve; + })); + + const first = h.run( + (client) => client.fetchUpdateAsync(), + 'canary', + { fetchTimeoutMs: 10 }, + ); + await expect(first).rejects.toThrow('ota-request-timeout(10ms)'); + expect(h.configs.at(-1)?.requestHeaders).toEqual({ 'x-cindy-update-channel': 'canary' }); + + const secondOperation = vi.fn((client: OtaRequestClient) => client.checkForUpdateAsync()); + const second = h.run(secondOperation); + await Promise.resolve(); + expect(secondOperation).not.toHaveBeenCalled(); + + finishNativeFetch({ isNew: true, manifest: { id: 'u2' } }); + await expect(second).resolves.toMatchObject({ isAvailable: false }); + + expect(secondOperation).toHaveBeenCalledOnce(); + expect(JSON.parse(h.getStored()!)).toMatchObject({ + mode: 'shared', + updateId: 'u2', + updateUrl: UPDATE_URL, + }); + expect(h.configs.at(-1)?.requestHeaders[EAS_CLIENT_ID_HEADER]).toBe(SHARED_OTA_CLIENT_ID); + }); + + it('check 超时先恢复 U1,并等待原生结果晚到后才放行下一笔事务', async () => { + const h = createHarness(); + let finishNativeCheck!: (result: { isAvailable: boolean }) => void; + vi.mocked(h.client.checkForUpdateAsync).mockImplementationOnce(() => new Promise((resolve) => { + finishNativeCheck = resolve; + })); + + const first = h.run( + (client) => client.checkForUpdateAsync(), + 'canary', + { checkTimeoutMs: 10 }, + ); + await expect(first).rejects.toThrow('ota-request-timeout(10ms)'); + expect(h.configs.at(-1)?.requestHeaders).toEqual({ 'x-cindy-update-channel': 'canary' }); + + const secondOperation = vi.fn((client: OtaRequestClient) => client.checkForUpdateAsync()); + const second = h.run(secondOperation); + await Promise.resolve(); + expect(secondOperation).not.toHaveBeenCalled(); + + finishNativeCheck({ isAvailable: false }); + await expect(second).resolves.toMatchObject({ isAvailable: false }); + expect(secondOperation).toHaveBeenCalledOnce(); + expect(h.configs.at(-1)?.requestHeaders).toEqual({ 'x-cindy-update-channel': 'canary' }); + }); + + it('首次 legacy baseline 写入失败时不改原生配置,也不发 OTA 请求', async () => { + const h = createHarness(); + h.writeBaseline.mockRejectedValueOnce(new Error('storage unavailable')); + + await expect(h.run((client) => client.checkForUpdateAsync())) + .rejects.toThrow('storage unavailable'); + + expect(h.setConfigOverride).not.toHaveBeenCalled(); + expect(h.client.checkForUpdateAsync).not.toHaveBeenCalled(); + expect(h.getStored()).toBeNull(); + }); + + it('首次设置 shared override 抛错时尽力恢复 U1 配置', async () => { + const h = createHarness(); + h.setConfigOverride.mockImplementationOnce(() => { + throw new Error('native override rejected'); + }); + + await expect(h.run((client) => client.checkForUpdateAsync())) + .rejects.toThrow('native override rejected'); + + expect(h.client.checkForUpdateAsync).not.toHaveBeenCalled(); + expect(h.configs.at(-1)).toEqual({ + updateUrl: UPDATE_URL, + requestHeaders: { 'x-cindy-update-channel': 'canary' }, + }); + }); + + it('启动、回前台和手动检查共用串行队列,不会交错改写 override', async () => { + const h = createHarness(); + let releaseFirst!: () => void; + const first = h.run(async (client) => { + await client.checkForUpdateAsync(); + await new Promise((resolve) => { releaseFirst = resolve; }); + return 'first'; + }); + const secondOperation = vi.fn(async (client: OtaRequestClient) => { + await client.checkForUpdateAsync(); + return 'second'; + }); + const second = h.run(secondOperation); + + await vi.waitFor(() => expect(h.client.checkForUpdateAsync).toHaveBeenCalledTimes(1)); + expect(secondOperation).not.toHaveBeenCalled(); + releaseFirst(); + + await expect(Promise.all([first, second])).resolves.toEqual(['first', 'second']); + expect(secondOperation).toHaveBeenCalledOnce(); + expect(h.client.checkForUpdateAsync).toHaveBeenCalledTimes(2); + }); + + it('拿不到当前 update ID 时不改原生配置也不发请求', async () => { + const h = createHarness({ currentUpdateId: null }); + + await expect(h.run((client) => client.checkForUpdateAsync())) + .rejects.toThrow('ota-request-current-update-id-unavailable'); + + expect(h.setConfigOverride).not.toHaveBeenCalled(); + expect(h.client.checkForUpdateAsync).not.toHaveBeenCalled(); + }); + + it('emergency launch 没有 update ID 时仍可执行后台恢复请求', async () => { + nativeState.updateId = null; + nativeState.isEmergencyLaunch = true; + nativeUpdates.checkForUpdateAsync.mockResolvedValueOnce({ isAvailable: false }); + + await expect(runSelfHostedOtaRequest( + 'release', + (client) => client.checkForUpdateAsync(), + )).resolves.toMatchObject({ isAvailable: false }); + + expect(nativeUpdates.checkForUpdateAsync).toHaveBeenCalledOnce(); + expect(nativeUpdates.setUpdateURLAndRequestHeadersOverride).toHaveBeenNthCalledWith(1, { + updateUrl: UPDATE_URL, + requestHeaders: { [EAS_CLIENT_ID_HEADER]: SHARED_OTA_CLIENT_ID }, + }); + expect(nativeUpdates.setUpdateURLAndRequestHeadersOverride).toHaveBeenLastCalledWith({ + updateUrl: UPDATE_URL, + requestHeaders: {}, + }); + }); +}); diff --git a/apps/mobile/src/update/otaRequestCoordinator.ts b/apps/mobile/src/update/otaRequestCoordinator.ts new file mode 100644 index 00000000000..7278a0833af --- /dev/null +++ b/apps/mobile/src/update/otaRequestCoordinator.ts @@ -0,0 +1,386 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Updates from 'expo-updates'; + +import type { UpdateChannel } from '@cindy/maker-shared/update-channel'; + +import { OTA_SERVER_BASE_URL } from '@/config/env'; +import { updateChannelRequestHeaders } from './canaryChannelStore'; + +/** + * expo-updates 要求 EAS-Client-ID 是合法 UUID;自建 OTA 全设备共用这个非设备标识值。 + * 它只进入 JS runtime override,不进入 app.config.js,因此不会改变 runtime fingerprint。 + */ +export const SHARED_OTA_CLIENT_ID = '00000000-0000-4000-8000-000000000000'; +export const EAS_CLIENT_ID_HEADER = 'EAS-Client-ID'; + +const STORAGE_KEY = 'cindy.mobile.update.request-header-baseline.v1'; +const EMERGENCY_BASELINE_UPDATE_ID = 'embedded-emergency-launch'; + +// Bootstrap 边界:已经内置 #3359 且 consent=false 的旧客户端不会请求任何 OTA,尚未 +// 到达设备的 JS 无法解除这道门。用户必须先同意一次以取得首个 bridge OTA;bridge +// 启动后,后续自建 OTA 才全部通过本协调器匿名检查。卸载重装同一旧整包不能改变该边界。 + +export interface OtaCheckResult { + isAvailable: boolean; + manifest?: { id?: string }; +} + +export interface OtaFetchResult { + isNew: boolean; + manifest?: { id?: string }; +} + +/** 一次 OTA 事务可用的原生能力;三条检查路径都通过同一个实例串行执行。 */ +export interface OtaRequestClient { + checkForUpdateAsync: () => Promise; + fetchUpdateAsync: () => Promise; + reloadAsync: () => Promise; +} + +type HeaderMode = 'legacy' | 'shared'; + +interface HeaderBaseline { + version: 1; + /** 当前可启动 bundle 下载时使用的自定义 requestHeaders 形态。 */ + mode: HeaderMode; + updateId: string; + runtimeVersion: string; + updateUrl: string; + channel: UpdateChannel; +} + +interface UpdateRequestConfig { + updateUrl: string; + requestHeaders: Record; +} + +interface CoordinatorRunOptions { + updateUrl: string; + channel: UpdateChannel; + currentUpdateId: string | null; + currentRuntimeVersion: string | null; + checkTimeoutMs?: number; + fetchTimeoutMs?: number; +} + +interface CoordinatorDeps { + readBaseline: () => Promise; + writeBaseline: (raw: string) => Promise; + setConfigOverride: (config: UpdateRequestConfig) => void; + client: OtaRequestClient; +} + +export interface OtaRequestCoordinator { + run: ( + options: CoordinatorRunOptions, + operation: (client: OtaRequestClient) => Promise, + ) => Promise; +} + +export interface OtaRequestTimeouts { + checkTimeoutMs?: number; + fetchTimeoutMs?: number; +} + +const DEFAULT_CHECK_TIMEOUT_MS = 10_000; +const DEFAULT_FETCH_TIMEOUT_MS = 60_000; + +function withRequestTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`ota-request-timeout(${ms}ms)`)), ms); + promise.then( + (value) => { clearTimeout(timer); resolve(value); }, + (error) => { clearTimeout(timer); reject(error); }, + ); + }); +} + +/** 目标请求头:保留发布通道,只把 expo-updates 的单安装 ID 覆盖成共享 UUID。 */ +export function sharedOtaRequestHeaders(channel: UpdateChannel): Record { + return { + ...updateChannelRequestHeaders(channel), + [EAS_CLIENT_ID_HEADER]: SHARED_OTA_CLIENT_ID, + }; +} + +function legacyOtaRequestHeaders(channel: UpdateChannel): Record { + return updateChannelRequestHeaders(channel); +} + +function requestConfig( + updateUrl: string, + mode: HeaderMode, + channel: UpdateChannel, +): UpdateRequestConfig { + return { + updateUrl, + requestHeaders: mode === 'shared' + ? sharedOtaRequestHeaders(channel) + : legacyOtaRequestHeaders(channel), + }; +} + +function sameRequestConfig(left: UpdateRequestConfig, right: UpdateRequestConfig): boolean { + if (left.updateUrl !== right.updateUrl) return false; + const leftEntries = Object.entries(left.requestHeaders); + const rightEntries = Object.entries(right.requestHeaders); + return leftEntries.length === rightEntries.length + && leftEntries.every(([key, value]) => right.requestHeaders[key] === value); +} + +function manifestUpdateId(manifest: { id?: string } | undefined): string | null { + const id = manifest?.id; + return typeof id === 'string' && id.trim() ? id.toLowerCase() : null; +} + +function parseBaseline(raw: string | null): HeaderBaseline | null { + if (!raw) return null; + try { + const value = JSON.parse(raw) as Partial; + if ( + value.version !== 1 + || (value.mode !== 'legacy' && value.mode !== 'shared') + || typeof value.updateId !== 'string' + || !value.updateId.trim() + || typeof value.runtimeVersion !== 'string' + || !value.runtimeVersion.trim() + || typeof value.updateUrl !== 'string' + || !value.updateUrl.trim() + || (value.channel !== 'release' && value.channel !== 'canary' && value.channel !== 'beta') + ) { + return null; + } + return { + version: 1, + mode: value.mode, + updateId: value.updateId.toLowerCase(), + runtimeVersion: value.runtimeVersion, + updateUrl: value.updateUrl, + channel: value.channel, + }; + } catch { + return null; + } +} + +function unavailableResult(result: OtaCheckResult): OtaCheckResult { + return { ...result, isAvailable: false, manifest: undefined }; +} + +function notNewResult(result: OtaFetchResult): OtaFetchResult { + return { ...result, isNew: false, manifest: undefined }; +} + +/** + * 创建两阶段 requestHeaders 协调器。 + * + * U1 仍由旧请求头下载并启动,所以无更新、同 ID、异常或超时时必须恢复这组旧请求头; + * 只有用共享 UUID 成功下载了不同 ID 的 U2,并先把 U2 的基线落盘后,才允许持久保留 + * 共享请求头。expo-updates 的 override 自身会原生持久化,这个顺序是避免下次冷启动 + * 筛选不到 U1 的关键。 + */ +export function createOtaRequestCoordinator(deps: CoordinatorDeps): OtaRequestCoordinator { + let queue: Promise = Promise.resolve(); + let baseline: HeaderBaseline | null = null; + + async function persistBaseline(next: HeaderBaseline): Promise { + await deps.writeBaseline(JSON.stringify(next)); + baseline = next; + } + + async function resolveBaseline(options: CoordinatorRunOptions): Promise { + const currentUpdateId = options.currentUpdateId?.toLowerCase() ?? null; + if (!currentUpdateId) throw new Error('ota-request-current-update-id-unavailable'); + const currentRuntimeVersion = options.currentRuntimeVersion?.trim() ?? ''; + if (!currentRuntimeVersion) throw new Error('ota-request-runtime-version-unavailable'); + + if (baseline) { + // shared + 不同 ID 表示 U2 已下载、但本进程仍在跑 U1;它仍是下一次启动的基线。 + if ( + baseline.runtimeVersion === currentRuntimeVersion + && (baseline.mode === 'shared' || baseline.updateId === currentUpdateId) + ) return baseline; + baseline = null; + } + + const stored = parseBaseline(await deps.readBaseline()); + if ( + stored?.runtimeVersion === currentRuntimeVersion + && (stored.mode === 'shared' || stored.updateId === currentUpdateId) + ) { + baseline = stored; + return stored; + } + + // 第一次运行 U1:此时原生正是按旧 header + 当前持久通道选中了它,先把该基线钉住。 + const initial: HeaderBaseline = { + version: 1, + mode: 'legacy', + updateId: currentUpdateId, + runtimeVersion: currentRuntimeVersion, + updateUrl: options.updateUrl, + channel: options.channel, + }; + await persistBaseline(initial); + return initial; + } + + async function execute( + options: CoordinatorRunOptions, + operation: (client: OtaRequestClient) => Promise, + ): Promise<{ result: Promise; drained: Promise }> { + const currentUpdateId = options.currentUpdateId?.toLowerCase() ?? null; + const currentRuntimeVersion = options.currentRuntimeVersion?.trim() ?? ''; + if (!currentRuntimeVersion) throw new Error('ota-request-runtime-version-unavailable'); + const startingBaseline = await resolveBaseline(options); + const targetConfig = requestConfig(options.updateUrl, 'shared', options.channel); + const pendingNativeRequests: Promise[] = []; + let appliedConfig: UpdateRequestConfig | null = null; + + function trackNativeRequest(promise: Promise): Promise { + pendingNativeRequests.push(promise); + return promise; + } + + function restoreBaseline(): void { + // baseline 可能已由成功 fetch 原子推进到 U2;否则恢复进入事务前的 U1 配置。 + const finalBaseline = baseline ?? startingBaseline; + const config = requestConfig( + finalBaseline.updateUrl, + finalBaseline.mode, + finalBaseline.channel, + ); + if (appliedConfig && sameRequestConfig(appliedConfig, config)) return; + deps.setConfigOverride(config); + appliedConfig = config; + } + + const coordinatedClient: OtaRequestClient = { + checkForUpdateAsync: async () => { + // 原生请求不会被 JS timer 取消。raw promise 由事务单独追踪:调用方可按预算 + // fail-open,但串行队列要等它真正结束,避免晚到结果与下一事务交错。 + const result = await withRequestTimeout( + trackNativeRequest(deps.client.checkForUpdateAsync()), + options.checkTimeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS, + ); + const targetUpdateId = manifestUpdateId(result.manifest); + // 同一 update ID 无法靠重下改写数据库中的 requestHeaders;直接按无更新收口, + // 避免 U1 在共享 header 下把自己误判成可迁移的 U2。 + if ( + result.isAvailable + && targetUpdateId + && (targetUpdateId === currentUpdateId || targetUpdateId === baseline?.updateId) + ) { + return unavailableResult(result); + } + return result; + }, + fetchUpdateAsync: async () => { + // marker 更新属于原生 fetch 的完成处理,也必须被 drain 追踪。即使外层先超时, + // 晚到的成功下载仍会在队列锁内提交 shared 基线,不会留下永远无法启动的 U2。 + const nativeFetch = deps.client.fetchUpdateAsync().then(async (result) => { + if (!result.isNew) return result; + const targetUpdateId = manifestUpdateId(result.manifest); + if (!targetUpdateId) throw new Error('ota-request-fetched-update-id-unavailable'); + if (targetUpdateId === currentUpdateId || targetUpdateId === baseline?.updateId) { + return notNewResult(result); + } + + // fetch 已完成后、reload 或进程退出前先提交新基线;写失败会让 baseline 保持 + // 原值,事务恢复旧配置,因此不会主动留下没有迁移标记的 shared override。 + await persistBaseline({ + version: 1, + mode: 'shared', + updateId: targetUpdateId, + runtimeVersion: currentRuntimeVersion, + updateUrl: options.updateUrl, + channel: options.channel, + }); + return result; + }); + return withRequestTimeout( + trackNativeRequest(nativeFetch), + options.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS, + ); + }, + reloadAsync: deps.client.reloadAsync, + }; + + try { + deps.setConfigOverride(targetConfig); + appliedConfig = targetConfig; + } catch (error) { + // 原生实现会先持久化 override 再重建内存配置;即使后一步抛错,也尽力恢复 + // 已知可启动基线,不能假设一次失败的调用完全没有副作用。 + restoreBaseline(); + throw error; + } + const result = Promise.resolve() + .then(() => operation(coordinatedClient)) + // 逻辑预算到期时先恢复可启动基线并把结果交给 UI;底层原生请求继续由 drained + // 持锁收尾。这样启动页不会被慢下载卡住,持久 override 也不会长时间停在 shared。 + .finally(restoreBaseline); + const drained = result + .then(() => undefined, () => undefined) + .then(async () => { + await Promise.allSettled(pendingNativeRequests); + // 晚到 fetch 可能刚把 baseline 推进为 shared;在释放队列前同步最终配置。 + restoreBaseline(); + }); + return { result, drained }; + } + + return { + run( + options: CoordinatorRunOptions, + operation: (client: OtaRequestClient) => Promise, + ): Promise { + const transaction = queue.then(() => execute(options, operation)); + const result = transaction.then((started) => started.result); + queue = transaction + .then((started) => started.drained) + .then(() => undefined, () => undefined); + return result; + }, + }; +} + +const nativeCoordinator = createOtaRequestCoordinator({ + readBaseline: () => AsyncStorage.getItem(STORAGE_KEY), + writeBaseline: (raw) => AsyncStorage.setItem(STORAGE_KEY, raw), + setConfigOverride: (config) => Updates.setUpdateURLAndRequestHeadersOverride(config), + client: { + checkForUpdateAsync: () => Updates.checkForUpdateAsync(), + fetchUpdateAsync: () => Updates.fetchUpdateAsync(), + reloadAsync: () => Updates.reloadAsync(), + }, +}); + +/** 自建 OTA 的唯一网络入口;启动、回前台、手动检查必须全部走这里。 */ +export function runSelfHostedOtaRequest( + channel: UpdateChannel, + operation: (client: OtaRequestClient) => Promise, + { + checkTimeoutMs = DEFAULT_CHECK_TIMEOUT_MS, + fetchTimeoutMs = DEFAULT_FETCH_TIMEOUT_MS, + }: OtaRequestTimeouts = {}, +): Promise { + if (!OTA_SERVER_BASE_URL) { + return Promise.reject(new Error('endpoint manifest missing mobileUpdateBaseUrl')); + } + return nativeCoordinator.run({ + updateUrl: `${OTA_SERVER_BASE_URL}/manifest`, + channel, + // emergency launch 使用 NoDatabaseLauncher,expo-updates 不暴露 launched update ID。 + // 合成值只用于暂存 legacy 基线;成功 fetch 后会立刻被真实的 U2 manifest ID 替换。 + currentUpdateId: Updates.updateId + ?? (Updates.isEmergencyLaunch ? EMERGENCY_BASELINE_UPDATE_ID : null), + currentRuntimeVersion: Updates.runtimeVersion, + checkTimeoutMs, + fetchTimeoutMs, + }, operation); +} + +export const __testing = { + storageKey: STORAGE_KEY, +}; diff --git a/apps/mobile/src/update/resumeUpdateCheck.test.ts b/apps/mobile/src/update/resumeUpdateCheck.test.ts index a008b6d4ce2..bb6bae51d1f 100644 --- a/apps/mobile/src/update/resumeUpdateCheck.test.ts +++ b/apps/mobile/src/update/resumeUpdateCheck.test.ts @@ -110,29 +110,19 @@ describe('createResumeUpdateChecker OTA 静默路径', () => { expect(deps.checkForUpdateAsync).not.toHaveBeenCalled(); }); - it('isConsented=false → OTA skipped,整包检查仍按 bundleCheckEnabled 放行', async () => { - // 未同意隐私政策时不得发起带 eas-client-id 的 manifest 请求;整包 /latest 匿名不受影响。 - const deps = makeDeps({ isConsented: () => false }); - const { ota, bundle } = await runOnce(deps); - expect(ota).toBe('skipped'); - expect(deps.checkForUpdateAsync).not.toHaveBeenCalled(); - expect(bundle).toBe('up-to-date'); - }); - - it('isConsented=true → 走正常 OTA 检查', async () => { - const deps = makeDeps({ isConsented: () => true }); + it('不再依赖 analytics consent,走正常 OTA 检查', async () => { + const deps = makeDeps(); const { ota } = await runOnce(deps); expect(ota).toBe('up-to-date'); expect(deps.checkForUpdateAsync).toHaveBeenCalledOnce(); }); - it('check 期间撤销同意 → 下载前跳过,不 fetch', async () => { - // check 返回后、fetch 前同意被撤回:必须再问一次,不能继续下载带标识的资源。 - let consented = true; + it('check 期间账号切换 → 下载前跳过,不 fetch', async () => { + let current = true; const deps = makeDeps({ - isConsented: () => consented, + isCurrent: () => current, checkForUpdateAsync: vi.fn(async () => { - consented = false; // check 进行中用户登出,同意被清 + current = false; return { isAvailable: true }; }), }); @@ -142,6 +132,53 @@ describe('createResumeUpdateChecker OTA 静默路径', () => { expect(deps.fetchUpdateAsync).not.toHaveBeenCalled(); }); + it('排队等待包装器期间账号切换 → 请求前跳过,不访问旧 channel', async () => { + let current = true; + let releaseQueue!: () => void; + const wrappedCheck = vi.fn(async () => ({ isAvailable: false })); + const withOtaClient: NonNullable = + vi.fn(async (operation) => { + await new Promise((resolve) => { releaseQueue = resolve; }); + return operation({ + checkForUpdateAsync: wrappedCheck, + fetchUpdateAsync: vi.fn(async () => ({ isNew: false })), + }); + }); + const deps = makeDeps({ + withOtaClient, + isCurrent: () => current, + }); + + const pending = runOnce(deps); + await vi.waitFor(() => expect(withOtaClient).toHaveBeenCalledOnce()); + current = false; + releaseQueue(); + + await expect(pending).resolves.toMatchObject({ ota: 'skipped' }); + expect(wrappedCheck).not.toHaveBeenCalled(); + expect(deps.checkForUpdateAsync).not.toHaveBeenCalled(); + }); + + it('用同一个包装器覆盖完整 check → fetch 事务', async () => { + const wrappedCheck = vi.fn(async () => ({ isAvailable: true })); + const wrappedFetch = vi.fn(async () => ({ isNew: true })); + const withOtaClient: NonNullable = + vi.fn(async (operation) => operation({ + checkForUpdateAsync: wrappedCheck, + fetchUpdateAsync: wrappedFetch, + })); + const deps = makeDeps({ withOtaClient }); + + const { ota } = await runOnce(deps); + + expect(ota).toBe('fetched'); + expect(withOtaClient).toHaveBeenCalledOnce(); + expect(wrappedCheck).toHaveBeenCalledOnce(); + expect(wrappedFetch).toHaveBeenCalledOnce(); + expect(deps.checkForUpdateAsync).not.toHaveBeenCalled(); + expect(deps.fetchUpdateAsync).not.toHaveBeenCalled(); + }); + it('无可用更新 → up-to-date,不 fetch', async () => { const deps = makeDeps(); const { ota } = await runOnce(deps); diff --git a/apps/mobile/src/update/resumeUpdateCheck.ts b/apps/mobile/src/update/resumeUpdateCheck.ts index 366965b8aa5..003d1677b7a 100644 --- a/apps/mobile/src/update/resumeUpdateCheck.ts +++ b/apps/mobile/src/update/resumeUpdateCheck.ts @@ -27,15 +27,19 @@ export interface ResumeUpdateOutcome { bundle: ResumeBundleOutcome; } +export interface ResumeOtaClient { + checkForUpdateAsync: () => Promise<{ isAvailable: boolean }>; + fetchUpdateAsync: () => Promise<{ isNew: boolean }>; +} + export interface ResumeUpdateCheckDeps { /** JS OTA 是否启用(自建变体 + 非 dev + expo-updates 可用),与启动热更门同一 gate。 */ otaEnabled: boolean; /** - * 隐私同意闸门(运行时动态判定,非挂载期快照):用户同意《隐私政策》前不得发起 - * 带 eas-client-id 的 manifest / OTA 资源请求。缺省视为「未同意」与否由调用方决定; - * 这里只在调用方提供了判定函数时生效,纯逻辑层不引入 analytics 依赖。 + * 自建 OTA 注入事务式请求头协调器;缺省时直接使用下面两个方法,便于纯逻辑单测。 + * 包装必须覆盖完整 check → fetch,不能拆成两个锁,否则并发路径可能改写中间配置。 */ - isConsented?: () => boolean; + withOtaClient?: (operation: (client: ResumeOtaClient) => Promise) => Promise; checkForUpdateAsync: () => Promise<{ isAvailable: boolean }>; fetchUpdateAsync: () => Promise<{ isNew: boolean }>; /** 整包检查是否启用(自建变体),与 useBundleUpdatePrompt 同一 gate。 */ @@ -100,20 +104,24 @@ export function createResumeUpdateChecker( let inFlight = false; async function runOtaCheck(): Promise { - // 整包 /latest 是匿名请求(无稳定标识),不在此列;只有 OTA 的 manifest/资源 - // 会携带 eas-client-id,必须经隐私同意闸门。 - if (!deps.otaEnabled || (deps.isConsented && !deps.isConsented())) return 'skipped'; + if (!deps.otaEnabled) return 'skipped'; try { - const check = await withTimeout(deps.checkForUpdateAsync(), checkTimeoutMs); - if (deps.isCurrent && !deps.isCurrent()) return 'skipped'; - if (!check.isAvailable) return 'up-to-date'; - // check 期间用户可能已登出撤销同意(clearAnalyticsConsent 把 consent 翻 false): - // 下载前再问一次,避免同意被撤回后仍发起带 eas-client-id 的资源请求。 - if (deps.isConsented && !deps.isConsented()) return 'skipped'; - const fetched = await withTimeout(deps.fetchUpdateAsync(), fetchTimeoutMs); - if (deps.isCurrent && !deps.isCurrent()) return 'skipped'; - // 静默路径到此为止:不 reload,新 bundle 下次冷启动生效。 - return fetched.isNew ? 'fetched' : 'up-to-date'; + const operation = async (client: ResumeOtaClient): Promise => { + // withOtaClient 可能正在等启动/手动检查释放串行队列。轮到本次事务时账号或 + // channel 可能已经变化,必须在真正发 manifest 请求前再次判旧,不能只检查 + // 入队时快照或迟到结果。 + if (deps.isCurrent && !deps.isCurrent()) return 'skipped'; + const check = await withTimeout(client.checkForUpdateAsync(), checkTimeoutMs); + if (deps.isCurrent && !deps.isCurrent()) return 'skipped'; + if (!check.isAvailable) return 'up-to-date'; + const fetched = await withTimeout(client.fetchUpdateAsync(), fetchTimeoutMs); + if (deps.isCurrent && !deps.isCurrent()) return 'skipped'; + // 静默路径到此为止:不 reload,新 bundle 下次冷启动生效。 + return fetched.isNew ? 'fetched' : 'up-to-date'; + }; + return deps.withOtaClient + ? await deps.withOtaClient(operation) + : await operation(deps); } catch { return 'error'; // fail-open:离线/超时静默放过,下次 resume 或冷启动再试 } diff --git a/apps/mobile/src/update/updateConsentGate.ts b/apps/mobile/src/update/updateConsentGate.ts index f3d33c0a560..78fea04e0ba 100644 --- a/apps/mobile/src/update/updateConsentGate.ts +++ b/apps/mobile/src/update/updateConsentGate.ts @@ -1,14 +1,11 @@ -// 隐私同意闸门(更新链路复用版)。 +// 非自建 EAS / TestFlight 手动更新的隐私同意闸门。 // -// 更新检查(manifest / OTA 资源)在用户同意《隐私政策》前不得联网:expo-updates -// 原生层会在每次请求里携带稳定的 eas-client-id(可关联安装标识),属于「未经同意 -// 收集、传输个人信息」的隐私合规风险。同意状态的本机真相就是 analyticsConsentStore -// 的 consent 字段(「用户是否明示同意过《隐私政策》」),这里只做语义别名,不新增 -// 存储、不重复维护第二份同意标记——同一台设备不该出现「统计已同意、更新却未同意」 -// 或反向的不一致。 +// 这些渠道仍由 expo-updates 携带单安装 eas-client-id,因此保留 #3359 的 consent 闸门。 +// 自建 OTA 已改由 otaRequestCoordinator 在每次网络事务前覆盖全设备共享 UUID,不再复用 +// analytics consent。TapDB 自身仍以 analyticsConsentStore 为唯一真相,本文件不修改它。 // -// 原生层已通过 checkAutomatically:'NEVER' 关闭自动联网,唯一的 /manifest 泄漏源是 -// JS 手动 checkForUpdateAsync();在 JS 层用本闸门前置拦截即可根治,无需动原生配置。 +// 这里只保留语义别名,不新增或修改任何 consent 存储;hydrate / subscribe 导出继续 +// 保持兼容,当前更新链只有设置页的非自建手动检查读取同步状态。 import { getAnalyticsConsentState, @@ -16,7 +13,7 @@ import { subscribeAnalyticsConsent, } from '@/analytics/analyticsConsentStore'; -/** 冷启动 hydrate 一次,返回是否已同意。读取失败一律 fail-closed 到 false。 */ +/** hydrate analytics consent,读取失败语义由底层 store 统一收敛。 */ export async function hydratePrivacyConsent(): Promise { await hydrateAnalyticsConsent(); return getAnalyticsConsentState().consent; @@ -27,11 +24,7 @@ export function hasPrivacyConsent(): boolean { return getAnalyticsConsentState().consent; } -/** - * 订阅同意状态变化(登录页 acceptPrivacyConsent 翻 true、登出 clearAnalyticsConsent - * 翻 false 都会触发)。自建线「首启未同意 → 进程内同意」时,调用方需要据此补配置 - * OTA URL,否则设置页手动检查 / resume 静默检查会拿动态 true 却仍打占位地址。 - */ +/** 保留 analytics consent 的同源订阅,不维护第二份同意状态。 */ export function subscribePrivacyConsent(listener: () => void): () => void { return subscribeAnalyticsConsent(listener); } diff --git a/apps/mobile/src/update/useResumeUpdateCheck.ts b/apps/mobile/src/update/useResumeUpdateCheck.ts index d273a8951af..9da2467b466 100644 --- a/apps/mobile/src/update/useResumeUpdateCheck.ts +++ b/apps/mobile/src/update/useResumeUpdateCheck.ts @@ -19,7 +19,7 @@ import { fetchLatestRelease } from './fetchLatestRelease'; import { createResumeUpdateChecker } from './resumeUpdateCheck'; import { promptBundleUpdate } from './useBundleUpdatePrompt'; import { resolveUpdateChannelForDevice } from './canaryChannelStore'; -import { hasPrivacyConsent } from './updateConsentGate'; +import { runSelfHostedOtaRequest } from './otaRequestCoordinator'; import type { UpdateChannel } from '@cindy/maker-shared/update-channel'; export function useResumeUpdateCheck( @@ -38,7 +38,7 @@ export function useResumeUpdateCheck( let current = true; const checker = createResumeUpdateChecker({ otaEnabled: IS_OTA_SELFHOST && !__DEV__ && Updates.isEnabled, - isConsented: hasPrivacyConsent, + withOtaClient: (operation) => runSelfHostedOtaRequest(channel, operation), checkForUpdateAsync: () => Updates.checkForUpdateAsync(), fetchUpdateAsync: () => Updates.fetchUpdateAsync(), bundleCheckEnabled, diff --git a/apps/mobile/src/update/useStartupOtaGate.ts b/apps/mobile/src/update/useStartupOtaGate.ts index f218713d3f1..081737b51ee 100644 --- a/apps/mobile/src/update/useStartupOtaGate.ts +++ b/apps/mobile/src/update/useStartupOtaGate.ts @@ -2,21 +2,16 @@ // loading 门(避免先显示旧 UI 再 reload 的闪帧)。gate 不满足(非自建 / dev / updates 不可用)时 // 直接 ready=true,不阻塞、不发起任何网络。判定逻辑在 startupOtaUpdate.ts(纯函数、已单测)。 -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import * as Updates from 'expo-updates'; -import { IS_OTA_SELFHOST, OTA_SERVER_BASE_URL, REVIEW_MODE } from '@/config/env'; +import { IS_OTA_SELFHOST, REVIEW_MODE } from '@/config/env'; import { runEmergencyOtaRecovery, runStartupOtaUpdate, type StartupOtaOutcome, } from './startupOtaUpdate'; -import { updateChannelRequestHeaders } from './canaryChannelStore'; -import { - hasPrivacyConsent, - hydratePrivacyConsent, - subscribePrivacyConsent, -} from './updateConsentGate'; import type { UpdateChannel } from '@cindy/maker-shared/update-channel'; +import { runSelfHostedOtaRequest, type OtaRequestClient } from './otaRequestCoordinator'; import { clearOtaReloadGuardIfLaunched, readOtaReloadGuard, @@ -63,100 +58,44 @@ export function useStartupOtaGate(channel: UpdateChannel = 'release'): boolean { // CLIENT_ENDPOINT_REVIEW_KEY)。REVIEW_MODE 是 live binding,本 hook 挂载在 // 端点闸门 ready 之后,读到的必是清单匹配结果。 const baseEnabled = IS_OTA_SELFHOST && !__DEV__ && Updates.isEnabled && !REVIEW_MODE; - // 隐私同意状态三态:null = 尚未 hydrate;false = 未同意(不联网);true = 已同意。 - // baseEnabled 为 false 时不需要读同意状态,直接置 false 走「非自建放行」路径。 - const [consent, setConsent] = useState(baseEnabled ? null : false); const [ready, setReady] = useState(!baseEnabled); const started = useRef(false); - const configuredChannelRef = useRef(null); - - const configureUpdateUrl = useCallback(() => { - if (!OTA_SERVER_BASE_URL) { - throw new Error('endpoint manifest missing mobileUpdateBaseUrl'); - } - Updates.setUpdateURLAndRequestHeadersOverride({ - updateUrl: `${OTA_SERVER_BASE_URL}/manifest`, - requestHeaders: updateChannelRequestHeaders(channel), - }); - configuredChannelRef.current = channel; - }, [channel]); - - // 冷启动先 hydrate 隐私同意状态;未同意前不联网、不配置 expo-updates 目标。 - // 读失败 fail-closed 到 false(与 analyticsConsentStore 同口径)。同时订阅后续 - // 同意变化:登录页 acceptPrivacyConsent 会在**本进程内**把 consent 翻 true,若这里 - // 只保留一次性快照,设置页手动检查 / resume 检查(动态读 hasPrivacyConsent)会拿到 - // true 却仍指向未覆写的占位 URL,更新检查失败且 canary/beta 通道 header 不生效。 - useEffect(() => { - if (!baseEnabled) return; - let cancelled = false; - hydratePrivacyConsent().then( - (ok) => { if (!cancelled) setConsent(ok); }, - () => { if (!cancelled) setConsent(false); }, - ); - const unsubscribe = subscribePrivacyConsent(() => { - if (cancelled) return; - setConsent((prev) => { - const ok = hasPrivacyConsent(); - return prev === ok ? prev : ok; - }); - }); - return () => { cancelled = true; unsubscribe(); }; - }, [baseEnabled]); - - // feature-flags 在登录/切账号后可能更新 channel;启动检查只跑一次,但 - // expo-updates 仍必须马上切换 request header,否则本进程会把下一个账号 - // 的请求发到上一个账号的 canary/stable 指针。stable 的空 header 也会 - // 覆盖掉之前的 canary header。仅在已同意后同步——未同意前不触碰联网目标。 - useEffect(() => { - if (!baseEnabled || consent !== true || configuredChannelRef.current === channel) return; - try { - configureUpdateUrl(); - } catch { - // 真正的启动检查会把配置异常按 fail-open 处理;这里仅提前同步配置, - // 失败不能阻断主界面或后续重试。 - } - }, [configureUpdateUrl, baseEnabled, consent, channel]); useEffect(() => { // 非自建变体:ready 初值已是 true,无需处理。 if (!baseEnabled) return; - // 同意状态尚未决出:保持 ready=false,继续挡住业务树,避免「先挂出旧 UI、 - // 待同意读回后再 reload」的闪帧,以及同意前误发 /manifest 请求。 - if (consent === null) return; - // 未同意:直接放行,不发起任何更新检查(manifest / OTA 资源都不碰)。 - if (consent === false) { - // 标记「启动检查已决定跳过」:此后即使用户在本进程内同意(consent 翻 true), - // 也只由 configureUpdateUrl effect 补配置 URL,绝不补跑一次 check→fetch→reload, - // 否则会把已进入登录页的会话闪屏重启。 - started.current = true; - setReady(true); - return; - } - // 已同意:走既有启动 OTA 流程。 if (started.current) return; started.current = true; // 只冷启一次(不随 resume 重跑) let cancelled = false; - const otaDeps = { + const otaDeps = (client: OtaRequestClient) => ({ enabled: true, - configureUpdateUrl, - checkForUpdateAsync: () => Updates.checkForUpdateAsync(), - fetchUpdateAsync: () => Updates.fetchUpdateAsync(), - reloadAsync: () => Updates.reloadAsync(), + // URL + requestHeaders 已由 runSelfHostedOtaRequest 事务式配置。 + configureUpdateUrl: () => undefined, + checkForUpdateAsync: client.checkForUpdateAsync, + fetchUpdateAsync: client.fetchUpdateAsync, + reloadAsync: client.reloadAsync, isEmergencyLaunch: () => Updates.isEmergencyLaunch, currentUpdateId: () => Updates.updateId, isReloadBlocked: async (targetUpdateId: string) => shouldBlockOtaReload(await readOtaReloadGuard(), targetUpdateId), recordReload: recordOtaReload, - }; - void runStartupOtaUpdate(otaDeps).then((outcome) => { + }); + + void runSelfHostedOtaRequest( + channel, + (client) => runStartupOtaUpdate(otaDeps(client)), + ).then((outcome) => { logStartupOtaLaunch(outcome); // emergency launch:门已放行,修复版热更改在后台找(绝不 reload,见 // runEmergencyOtaRecovery)。fire-and-forget——它的结果不影响本次启动, // 只是让下一次冷启动有机会跑上修复版,而不是等用户去清应用数据。 if (outcome === 'emergency-launch') { - void runEmergencyOtaRecovery(otaDeps).then((recovery) => { + void runSelfHostedOtaRequest( + channel, + (client) => runEmergencyOtaRecovery(otaDeps(client)), + ).then((recovery) => { console.info('[ota] emergency recovery', JSON.stringify({ recovery })); - }); + }).catch(() => undefined); } // 'reloading' 时 app 正在重启,保持 loading 门直到重启;其余情况放行进 App。 if (!cancelled && outcome !== 'reloading') setReady(true); @@ -167,7 +106,7 @@ export function useStartupOtaGate(channel: UpdateChannel = 'release'): boolean { if (!cancelled) setReady(true); }); return () => { cancelled = true; }; - }, [baseEnabled, consent, configureUpdateUrl]); + }, [baseEnabled, channel]); return ready; }