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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions apps/mobile/app/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -379,6 +386,7 @@ export default function SettingsScreen() {
checkBundleUpdate,
currentlyRunning.isEmergencyLaunch,
t,
updateChannel.channel,
updateCheckEnabled,
updatesEnabled,
]);
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/__tests__/mobileSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/__tests__/nativeAppConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
30 changes: 24 additions & 6 deletions apps/mobile/src/update/manualUpdateCheck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ManualUpdateCheckDeps['withOtaClient']> =
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 () => {
Expand All @@ -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 })),
Expand All @@ -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 失败原因未知,原始详情是唯一线索:不能被重启指引盖掉。
Expand Down
48 changes: 33 additions & 15 deletions apps/mobile/src/update/manualUpdateCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}

/** 统一更新检查所需的外部能力,由设置页注入真实 Expo / 整包更新实现。 */
export interface ManualUpdateCheckDeps {
/** 自建线传入整包检查;EAS 线省略后直接检查 OTA。 */
checkBundleUpdate?: () => Promise<BundleUpdateCheckOutcome>;
otaEnabled: boolean;
/**
* 隐私同意闸门(动态判定,非调用瞬间快照):manifest 请求前与资源下载前分别重查。
* 用户点击「检查更新」后、请求尚未完成时登出撤销同意,这里必须停止继续携带
* eas-client-id 的请求。缺省(未提供)视为不启用该闸门。
* 非自建 EAS / TestFlight 的隐私同意闸门(动态判定,非调用瞬间快照):manifest
* 请求前与资源下载前分别重查。自建 OTA 不传,由 withOtaClient 覆盖共享 UUID。
*/
isConsented?: () => boolean;
/** 自建 OTA 用它包住完整 check → fetch → reload 事务;EAS/TestFlight 不传。 */
withOtaClient?: <T>(operation: (client: ManualOtaClient) => Promise<T>) => Promise<T>;
checkOtaUpdate: () => Promise<{ isAvailable: boolean }>;
/** isNew 表示确实落盘了一个新 bundle(reload 失败时用它区分"已下载待重启"与"什么都没拿到")。 */
fetchOtaUpdate: () => Promise<{ isNew: boolean }>;
Expand All @@ -53,6 +60,7 @@ export async function runManualUpdateCheck({
checkBundleUpdate,
otaEnabled,
isConsented,
withOtaClient,
checkOtaUpdate,
fetchOtaUpdate,
reload,
Expand All @@ -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<ManualUpdateCheckOutcome> => {
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 已经落盘、
// 下次冷启动就会生效:这不是一次失败的检查,报"检查更新失败"只会让用户无从下手。
Expand Down
Loading