diff --git a/src/client.ts b/src/client.ts index 53882c14..8b620266 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,93 +1,18 @@ -import { DeviceEventEmitter, Platform } from 'react-native'; -import { - buildTime, - cInfo, - currentVersion, - currentVersionInfo, - getBundleHash, - isFirstTime, - isRolledBack, - nativeMethod, - PushyModule, - packageVersion, - pushyNativeEventEmitter, - rolledBackVersion, - setLocalHashInfo, - supportedDiffVersion, -} from './core'; -import { executeEndpointFallback } from './endpoint'; -import { - readErrorCode, - toUpdateError, - UpdateError, - type UpdateErrorCode, -} from './error'; -import { - type ErrorReportContext, - type ErrorReportingOptions, - installGlobalErrorHandler, - type SerializedException, - serializeException, -} from './errorReporting'; +import { Platform } from 'react-native'; +import { Activation } from './clientActivation'; +import { Checker } from './clientCheck'; +import { Downloader } from './clientDownload'; +import { NativeConfigSync } from './clientNativeConfig'; +import { type ReportEvent, Reporter } from './clientReporter'; +import { createProcessState } from './clientState'; +import { cInfo, isRolledBack, packageVersion, rolledBackVersion } from './core'; +import { UpdateError } from './error'; import i18n from './i18n'; -import { - resolveServerEventHash, - resolveServerEventType, - truncateDetail, -} from './telemetry'; -import type { - BeforeReloadContext, - CheckResult, - ClientOptions, - EventType, - ProgressData, - UpdateCheckState, - UpdateServerConfig, -} from './type'; -import { - buildCheckFingerprint, - buildCheckRequestBody, - type DownloadPlan, - type DownloadStrategyType, - decideDownload, - dedupeEndpoints, - isMirrorRetryableCode, - isValidCheckResult, - jsonDeepEqual, - nativeConfigsEquivalent, - resolveCheckResult, -} from './updateFlowCore'; -import { - assertWeb, - computeProgress, - DEFAULT_FETCH_TIMEOUT_MS, - fetchWithTimeout, - info, - log, - noop, - promiseAny, - setDebugLogging, - testUrls, - warn, -} from './utils'; +import type { ClientOptions, UpdateServerConfig } from './type'; +import { dedupeEndpoints } from './updateFlowCore'; +import { assertWeb, info, log, noop, setDebugLogging } from './utils'; -/** One report() event: the logger payload and the source of server telemetry. */ -interface ReportEvent { - type: EventType; - message?: string; - code?: UpdateErrorCode; - data?: Record; -} - -/** - * Receives every error the client reports, alongside the report event type. - * The UpdateProvider subscribes to surface errors as lastError/Alert; user - * code can subscribe too. Listeners run before any throwError rethrow. - */ -export type UpdateErrorListener = ( - error: UpdateError, - eventType: EventType -) => void; +export type { UpdateErrorListener } from './clientReporter'; const SERVER_PRESETS = { // cn @@ -115,16 +40,12 @@ const cloneServerConfig = (server: UpdateServerConfig): UpdateServerConfig => ({ queryUrls: server.queryUrls ? [...server.queryUrls] : undefined, }); -// How long after a resolved reloadUpdate the process may still be alive before -// the switch is considered not to have restarted the app (see switchVersion). -const RELOAD_WATCHDOG_MS = 5000; - -// Non-2xx check responses are usually HTML error pages; keep only the head -// for the error message (it can end up in an alert) and the logger. -const MAX_HTTP_ERROR_TEXT = 200; - assertWeb(); +// Created once per evaluation of this module, i.e. once per process. +const processState = createProcessState(); +export const sharedState = processState.shared; + const createDefaultClientOptions = (): ClientOptions => ({ appKey: '', autoMarkSuccess: true, @@ -136,92 +57,6 @@ const createDefaultClientOptions = (): ClientOptions => ({ disableErrorReporting: false, }); -export const sharedState: { - downloadingTasks: Record>; - // Progress callbacks per hash: concurrent downloadUpdate callers of the - // same hash each register theirs here instead of the second one being - // silently dropped by the in-flight dedup. - progressCallbacks: Record void>>; - downloadedHash?: string; - toHash?: string; - apkStatus: 'downloading' | 'downloaded' | null; - marked: boolean; - applyingUpdate: boolean; -} = { - downloadingTasks: {}, - progressCallbacks: {}, - downloadedHash: undefined, - apkStatus: null, - marked: false, - applyingUpdate: false, -}; - -// The native cold-start check writes its response cache at most once per -// launch (and skips it once JS has checked), so once this process has seen it -// absent or expired, later reads are near-certain misses: skip the bridge -// call from then on. The rare miss costs one network check. A fresh entry -// that only mismatches the request or config does not set this: a later check -// with the original options (e.g. after `extra` reverts) can still reuse it. -let nativeCheckCacheExhausted = false; -const NATIVE_CHECK_CACHE_MAX_AGE_S = 120; - -// In-flight native markSuccess shared by concurrent callers, so the native -// call and its telemetry happen once. Cleared once settled. -let markingPromise: Promise | undefined; - -// A reset/new switch invalidates ownership of older async completions and -// watchdogs before the process-wide applyingUpdate flag can be reused. -let applyingUpdateGeneration = 0; -let reloadWatchdogTimer: ReturnType | undefined; - -const clearReloadWatchdog = () => { - if (reloadWatchdogTimer !== undefined) { - clearTimeout(reloadWatchdogTimer); - reloadWatchdogTimer = undefined; - } -}; - -const beginApplyingUpdate = (): number => { - clearReloadWatchdog(); - sharedState.applyingUpdate = true; - return ++applyingUpdateGeneration; -}; - -const ownsApplyingUpdate = (generation: number): boolean => - generation === applyingUpdateGeneration && sharedState.applyingUpdate; - -const finishApplyingUpdate = (generation: number): boolean => { - if (!ownsApplyingUpdate(generation)) { - return false; - } - clearReloadWatchdog(); - sharedState.applyingUpdate = false; - return true; -}; - -const invalidateApplyingUpdate = () => { - applyingUpdateGeneration++; - clearReloadWatchdog(); - sharedState.applyingUpdate = false; -}; - -const armReloadWatchdog = (generation: number, onTimeout: () => void) => { - if (!ownsApplyingUpdate(generation)) { - return; - } - clearReloadWatchdog(); - const timer = setTimeout(() => { - // A cancelled callback may already be queued; both identities must match. - if (reloadWatchdogTimer !== timer || !ownsApplyingUpdate(generation)) { - return; - } - reloadWatchdogTimer = undefined; - sharedState.applyingUpdate = false; - onTimeout(); - }, RELOAD_WATCHDOG_MS); - reloadWatchdogTimer = timer; -}; - // The SDK is a process-level singleton: module-level sharedState, the global // i18n locale and the native update state are all per-process, so a second // client would silently share (and fight over) them. Constructing one is a @@ -229,48 +64,42 @@ const armReloadWatchdog = (generation: number, onTimeout: () => void) => { // client (same type + appKey), which dev fast-refresh triggers legitimately. let activeClient: Pushy | undefined; -const assertHash = (hash: string) => { - if (!sharedState.downloadedHash) { - log(`no downloaded hash yet, ignore switch to ${hash}`); - return; - } - if (hash !== sharedState.downloadedHash) { - log(`use downloaded hash ${sharedState.downloadedHash} first`); - return; - } - return true; -}; - +// The client is a facade: checking (clientCheck), downloading +// (clientDownload), activation (clientActivation), reporting (clientReporter) +// and the native config sync (clientNativeConfig) each own their part of the +// flow; this class owns the options and the process-level singleton, and +// keeps the public API. // for China users export class Pushy { options: ClientOptions = createDefaultClientOptions(); clientType: 'Pushy' | 'Cresc' = 'Pushy'; - lastChecking?: number; - lastRespJson?: Promise; - lastCheckFingerprint?: string; - // Endpoint that most recently served a successful checkUpdate; telemetry - // reuses it instead of re-running the fallback race. - private lastWorkingEndpoint?: string; - private syncedNativeConfigJson?: string; - private pendingNativeConfigJson?: string; - private nativeConfigSyncInFlight = false; - // Config last acknowledged by markJsCheckCompleted. - private jsCheckCompletedConfigJson?: string; - private reportedInvalidUpdates = new Set(); - private reportedExceptions = new WeakSet(); - private globalErrorCleanup?: () => void; - version = cInfo.rnu; - loggerPromise = (() => { - let resolve: (value?: unknown) => void = () => {}; - const promise = new Promise((res) => { - resolve = res; - }); - return { - promise, - resolve, - }; - })(); + + // `options` is mutated in place, never replaced (see optionsVersion), so + // the reporter can hold it directly. + private readonly reporter = new Reporter({ + options: this.options, + getEffectivePackageVersion: () => this.getEffectivePackageVersion(), + telemetryEndpoint: () => + this.checker.lastWorkingEndpoint || this.options.server?.main?.[0], + }); + private readonly nativeConfig = new NativeConfigSync(this); + private readonly checker = new Checker( + this, + this.reporter, + this.nativeConfig, + processState + ); + private readonly downloader = new Downloader( + this, + this.reporter, + processState + ); + private readonly activation = new Activation( + this, + this.reporter, + processState + ); constructor(options: ClientOptions, clientType?: 'Pushy' | 'Cresc') { this.clientType = clientType || 'Pushy'; @@ -343,7 +172,7 @@ export class Pushy { ? cloneServerConfig(value as UpdateServerConfig) : value; if (key === 'logger') { - this.loggerPromise.resolve(); + this.reporter.loggerPromise.resolve(); } if (key === 'locale') { // The constructor applies the initial locale; a runtime switch (or @@ -362,163 +191,8 @@ export class Pushy { log('onOptionsChange listener error:', e?.message || e); } } - this.syncAutomaticErrorReporting(); - this.syncNativeConfig(); - }; - - private syncAutomaticErrorReporting = (): void => { - if (this.options.disableTelemetry || this.options.disableErrorReporting) { - this.globalErrorCleanup?.(); - return; - } - this.enableErrorReporting(); - }; - - /** Build the subset of options used by the native cold-start check. */ - private getNativeConfig = (): Record | undefined => { - if (!nativeMethod('syncNativeConfig')) { - // Web, or an older native without the native check. - return undefined; - } - const { - appKey, - server, - updateStrategy, - checkStrategy, - disableNativeCheck, - } = this.options; - if (disableNativeCheck) { - // Explicit opt-out: keep the identity fields so the persisted config - // still describes this app, and let the orchestrators bail on `disabled` - // before any IO. - return { disabled: true, appKey, rnu: cInfo.rnu, rn: cInfo.rn }; - } - if (!appKey || !server?.main?.length) { - // Unusable rather than merely absent: the JS check would fail on these - // options too (NO_ENDPOINTS / APPKEY_REQUIRED). The caller turns this - // into an explicit disabled state. - return undefined; - } - // An app that turned automatic checks off (checkStrategy: null) must not - // be handed a version switch it never asked for. The cold-start check - // still runs and still downloads — that is what keeps a bricked device - // rescuable — but activation waits for the JS side, or for the server's - // explicit per-version forceBoot directive (shouldActivateAfterDownload). - const autoCheckEnabled = checkStrategy != null; - return { - appKey, - packageVersion: this.getEffectivePackageVersion(), - endpoints: server.main, - queryUrls: server.queryUrls ?? [], - // The native check may activate a downloaded version (next launch) - // only under the silent strategies; alert-style strategies keep - // activation with the JS side (§6/§10.1). - afterDownload: - autoCheckEnabled && - (updateStrategy === 'silentAndNow' || - updateStrategy === 'silentAndLater') - ? 'setNeedUpdate' - : 'none', - rnu: cInfo.rnu, - rn: cInfo.rn, - }; - }; - - private getNativeConfigJson = (): string | undefined => { - const config = this.getNativeConfig(); - return config ? JSON.stringify(config) : undefined; - }; - - private flushNativeConfig = () => { - if (this.options.nativeConfigSource === 'native') { - this.pendingNativeConfigJson = undefined; - this.syncedNativeConfigJson = undefined; - return; - } - const sync = nativeMethod('syncNativeConfig'); - if (!sync || this.nativeConfigSyncInFlight) { - return; - } - const configJson = this.pendingNativeConfigJson; - this.pendingNativeConfigJson = undefined; - if (!configJson || configJson === this.syncedNativeConfigJson) { - return; - } - this.nativeConfigSyncInFlight = true; - sync(configJson) - .then(() => { - // A switch to 'native' while this write was in flight hands storage - // to the host; remembering the write would skip it after switching - // back. - if (this.options.nativeConfigSource !== 'native') { - this.syncedNativeConfigJson = configJson; - } - }) - .catch((e: any) => { - log('syncNativeConfig failed:', e?.message || e); - }) - .finally(() => { - this.nativeConfigSyncInFlight = false; - this.flushNativeConfig(); - }); - }; - - /** - * Tell the native cold-start orchestrator that this process already holds a - * valid check response for the current config, so its delayed round (5s - * after launch) does not repeat the request. Fire-and-forget: a failure - * costs one duplicate check. Never called on a failed check — the native - * rescue must keep running when JS could not reach the server. - */ - private markJsCheckCompleted = () => { - const markCompleted = nativeMethod('markJsCheckCompleted'); - if (!markCompleted) { - return; - } - const configJson = this.getNativeConfigJson(); - if (!configJson || configJson === this.jsCheckCompletedConfigJson) { - return; - } - markCompleted(configJson).then( - () => { - this.jsCheckCompletedConfigJson = configJson; - }, - (e: any) => { - log('markJsCheckCompleted failed:', e?.message || e); - } - ); - }; - - private syncNativeConfig = () => { - if (this.options.nativeConfigSource === 'native') { - // The host owns the stored config now; forget what JS last wrote so a - // switch back to 'javascript' writes again instead of deduping - // against a value native storage no longer holds. - this.pendingNativeConfigJson = undefined; - this.syncedNativeConfigJson = undefined; - return; - } - if (!nativeMethod('syncNativeConfig')) { - return; - } - const configJson = this.getNativeConfigJson(); - if (!configJson) { - // No usable config (empty appKey / no endpoints) is almost always a - // transient state while options are being assembled. Persisting a - // disabled config over the last good one would switch off the - // cold-start rescue for every later launch, so keep what native has. - // (Web and older natives never reach this point — no method.) - log('native check config unusable, keeping the last synced config'); - return; - } - // Always record the latest desired value, even when it matches the last - // completed write. Example: A synced -> B in flight -> options revert to - // A. Comparing only with synced(A) would drop the revert and leave native - // storage at B after that in-flight write completes. - // Coalesce rapid setOptions calls, but serialize bridge writes so an older - // completion can never overwrite the newest desired configuration. - this.pendingNativeConfigJson = configJson; - this.flushNativeConfig(); + this.reporter.syncAutomaticErrorReporting(); + this.nativeConfig.syncNativeConfig(); }; /** Package version used by every server-side update decision. */ @@ -555,474 +229,6 @@ export class Pushy { return i18n.t(key as any, values); }; - reportInvalidUpdateOnce = ( - reason: 'missingHash' | 'noArtifact', - hash = '' - ) => { - const key = `${this.options.appKey}:${reason}:${hash}`; - if (this.reportedInvalidUpdates.has(key)) { - return; - } - this.reportedInvalidUpdates.add(key); - this.report({ - type: 'errorUpdate', - message: - reason === 'missingHash' - ? 'update response is missing a version hash' - : 'update response contains no downloadable artifact', - ...(hash ? { data: { newVersion: hash } } : {}), - }); - }; - - /** - * Best-effort JS exception reporting for the currently running OTA release. - * It is intentionally synchronous to call and never throws; the transport - * runs in the background and preserves the app's existing error flow. - */ - captureException = ( - error: unknown, - context: ErrorReportContext = {} - ): void => { - try { - if ( - __DEV__ || - this.options.disableTelemetry || - this.options.disableErrorReporting || - !currentVersion - ) { - return; - } - if ( - error !== null && - (typeof error === 'object' || typeof error === 'function') - ) { - const object = error as object; - if (this.reportedExceptions.has(object)) { - return; - } - this.reportedExceptions.add(object); - } - this.postReport({ - type: 'js_exception', - hash: currentVersion, - error: serializeException(error, context), - }); - } catch (e: any) { - log('exception telemetry error:', e?.message || e); - } - }; - - /** - * Ensure uncaught JS exception capture is installed and return a runtime - * cleanup function. Capture is installed automatically by default; this - * method remains an idempotent compatibility hook. The wrapper always - * chains the handler that was installed before it and only restores that - * handler when no later integration has replaced ours. - */ - enableErrorReporting = ( - options: ErrorReportingOptions = {} - ): (() => void) => { - if ( - options.captureGlobal === false || - this.options.disableTelemetry || - this.options.disableErrorReporting - ) { - return () => {}; - } - if (this.globalErrorCleanup) { - return this.globalErrorCleanup; - } - const uninstall = installGlobalErrorHandler((error, isFatal) => { - this.captureException(error, { fatal: isFatal === true }); - }); - let active = true; - const cleanup = () => { - if (!active) { - return; - } - active = false; - uninstall(); - if (this.globalErrorCleanup === cleanup) { - this.globalErrorCleanup = undefined; - } - }; - this.globalErrorCleanup = cleanup; - return cleanup; - }; - - report = async ({ type, message = '', code, data = {} }: ReportEvent) => { - log(`${type} ${code ? `[${code}] ` : ''}${message}`); - // Fire-and-forget server telemetry; must not wait for the logger below. - this.reportToServer({ type, message, code, data }); - if (this.options.logger === noop) { - // Wait briefly for a logger to arrive via setOptions (e.g. the rollback - // report fires in the constructor before the user configures one), but - // give up after a bound instead of retaining the closure forever when - // no logger is ever provided. - let timer: ReturnType | undefined; - await Promise.race([ - this.loggerPromise.promise, - new Promise((resolve) => { - timer = setTimeout(() => resolve(), 10 * 1000); - }), - ]); - clearTimeout(timer); - } - const { logger = noop, appKey } = this.options; - const overridePackageVersion = this.options.overridePackageVersion; - try { - logger({ - type, - data: { - appKey, - currentVersion, - cInfo, - packageVersion, - overridePackageVersion, - buildTime, - message, - code, - ...currentVersionInfo, - ...data, - }, - }); - } catch (e: any) { - // A user-provided logger must never break the update flow, and report() - // calls are fire-and-forget so a throw here would be an unhandled - // rejection. - log('logger error:', e?.message || e); - } - }; - /** - * Best-effort lifecycle event reporting to the update server (aggregate - * counts + sampled failure details power the version health view and the - * rollback safety net server-side). Single POST to the last known working - * endpoint, no retry, no fallback race; any failure is swallowed — telemetry - * must never affect the update flow. Opt out with disableTelemetry. - */ - private reportToServer = ({ - type, - message, - code, - data = {}, - }: ReportEvent) => { - try { - if (__DEV__ || this.options.disableTelemetry) { - return; - } - const serverType = resolveServerEventType(type, code); - if (!serverType) { - return; - } - const hash = resolveServerEventHash({ serverType, data, currentVersion }); - if (!hash) { - return; - } - // A download rescued by a later strategy reports its patch-health - // signal through downloadFallback, which only counts patch-apply - // failures; downloadSuccess's data.error (any earlier failure, network - // errors included) stays local. - this.postReport({ - type: serverType, - hash, - detail: truncateDetail(message), - }); - } catch (e: any) { - log('telemetry error:', e?.message || e); - } - }; - /** - * The single telemetry POST ({endpoint}/report/{appKey}) to the last known - * working endpoint; no retry, failures only logged. - */ - private postReport = ({ - type, - hash, - ...payload - }: { - type: string; - hash: string; - detail?: string; - error?: SerializedException; - }) => { - const { appKey } = this.options; - const endpoint = this.lastWorkingEndpoint || this.options.server?.main?.[0]; - if (!appKey || !endpoint) { - return; - } - fetchWithTimeout(`${endpoint}/report/${appKey}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type, - hash, - packageVersion: this.getEffectivePackageVersion(), - cInfo, - ...payload, - }), - }).catch((e: any) => { - log('telemetry report failed:', e?.message || e); - }); - }; - throwIfEnabled = (e: Error) => { - if (this.options.throwError) { - throw e; - } - }; - private errorListeners = new Set(); - private emittedErrors = new WeakSet(); - /** - * Subscribe to every error the client reports (regardless of throwError). - * Returns an unsubscribe function. - */ - onError = (listener: UpdateErrorListener) => { - this.errorListeners.add(listener); - return () => { - this.errorListeners.delete(listener); - }; - }; - /** - * Whether this exact error object already went through emitError (and was - * therefore delivered to onError subscribers). Lets UI layers decide if a - * caught error still needs surfacing — checking `e.code` is not enough, - * since axios/system errors carry their own code without ever entering the - * pipeline. - */ - wasEmitted = (e: unknown): boolean => - e instanceof Error && this.emittedErrors.has(e); - /** - * Single exit point for errors: reports to the logger (with the stable - * code) and notifies onError listeners. Whether to also throw stays with - * the caller (throwIfEnabled or an unconditional rethrow). - */ - private emitError = ( - error: UpdateError, - type: EventType, - { - message = error.message, - data, - }: { message?: string; data?: Record } = {} - ) => { - this.emittedErrors.add(error); - this.report({ - type, - message, - code: error.code, - // Structured context from the error (e.g. HTTP status) reaches the - // logger; explicit data wins on key conflicts. - data: error.extra ? { ...error.extra, ...data } : data, - }); - for (const listener of this.errorListeners) { - try { - listener(error, type); - } catch (e: any) { - log('onError listener error:', e?.message || e); - } - } - }; - /** - * toUpdateError + emitError: how a caught failure leaves the client. - * Returns the error for the caller to throw or pass to throwIfEnabled. - * `fallbackMessage` is reported when the error has no message of its own. - */ - private fail = ( - e: unknown, - code: UpdateErrorCode, - type: EventType, - { - message, - fallbackMessage, - data, - }: { - message?: string; - fallbackMessage?: string; - data?: Record; - } = {} - ): UpdateError => { - const err = toUpdateError(e, code); - this.emitError(err, type, { - message: message ?? (err.message || fallbackMessage), - data, - }); - return err; - }; - notifyAfterCheckUpdate = (state: UpdateCheckState) => { - const { afterCheckUpdate } = this.options; - if (!afterCheckUpdate) { - return; - } - // 这里仅做状态通知,不阻塞原有检查流程 - Promise.resolve(afterCheckUpdate(state)).catch((error: any) => { - log('afterCheckUpdate failed:', error?.message || error); - }); - }; - runBeforeReload = async (context: BeforeReloadContext) => { - const { beforeReload } = this.options; - if (!beforeReload) { - return true; - } - const shouldReload = await beforeReload(context); - if (shouldReload === false) { - log('beforeReload returned false, skipping reload'); - return false; - } - return true; - }; - // server.main is deduplicated on assignment (cloneServerConfig), and the - // endpoint plan dedupes again anyway. - getConfiguredCheckEndpoints = () => this.options.server?.main ?? []; - getRemoteEndpoints = async () => { - const { server } = this.options; - if (!server?.queryUrls?.length) { - return []; - } - try { - // Race the parsed lists, not the raw responses: fetch resolves for - // 404/403/5xx and anti-bot HTML too, and this path only runs once the - // main endpoints are already failing — a mirror that answers first - // with an error page must not beat the healthy one. - const remoteEndpoints = await promiseAny( - server.queryUrls.map(async (queryUrl) => { - const resp = await fetchWithTimeout(queryUrl, {}); - if (!resp.ok) { - throw Error(`${queryUrl}: ${resp.status}`); - } - const list: unknown = await resp.json(); - if (!Array.isArray(list)) { - throw Error(`${queryUrl}: not an endpoint list`); - } - return list; - }) - ); - log('fetch endpoints:', remoteEndpoints); - // Configured and already-failed endpoints are dropped by the endpoint - // fallback itself. - return dedupeEndpoints( - remoteEndpoints.filter( - // The list is fetched from a public mirror; it may add endpoints - // but never move the check outside TLS. - (endpoint): endpoint is string => - typeof endpoint === 'string' && /^https:\/\//i.test(endpoint) - ) - ); - } catch (e) { - log('failed to fetch endpoints from: ', server.queryUrls, e); - } - return []; - }; - requestCheckResult = async ( - endpoint: string, - fetchPayload: Parameters[1], - signal?: AbortSignal - ): Promise => { - try { - const resp = await fetchWithTimeout( - `${endpoint}/checkUpdate/${this.options.appKey}`, - fetchPayload, - DEFAULT_FETCH_TIMEOUT_MS, - signal - ); - - if (!resp.ok) { - const respText = (await resp.text()).slice(0, MAX_HTTP_ERROR_TEXT); - throw new UpdateError( - this.t('error_http_status', { - status: resp.status, - statusText: respText, - }), - 'HTTP_STATUS', - { extra: { status: resp.status } } - ); - } - - const result: unknown = await resp.json(); - if (!isValidCheckResult(result)) { - // A 2xx that is not a verdict (`{"error": ...}`, a captive-portal - // page that parsed, ...) is a failed endpoint: throwing keeps the - // fallback moving to the next one instead of treating it as "no - // update". - throw new UpdateError( - this.t('error_invalid_check_response'), - 'INVALID_RESPONSE' - ); - } - return result; - } catch (e) { - log('check endpoint failed', endpoint, e); - throw e; - } - }; - fetchCheckResult = async (fetchPayload: Parameters[1]) => { - const { endpoint, value } = await executeEndpointFallback({ - configuredEndpoints: this.getConfiguredCheckEndpoints(), - getRemoteEndpoints: this.getRemoteEndpoints, - tryEndpoint: (currentEndpoint, signal) => - this.requestCheckResult(currentEndpoint, fetchPayload, signal), - onFirstFailure: ({ error }) => { - this.report({ - type: 'errorChecking', - message: this.t('error_cannot_connect_backup', { - message: error.message, - }), - }); - }, - }); - - log('check endpoint success', endpoint); - this.lastWorkingEndpoint = endpoint; - return value; - }; - /** - * Reuse the native cold-start check's cached response when fresh - * (NATIVE_CHECKUPDATE_DESIGN §10.3) instead of re-checking. Returns - * undefined whenever the cache is absent, stale, or unreadable — any - * failure falls through to a normal network check. - */ - private readNativeCheckCache = async ( - requestBody: Record - ): Promise => { - try { - const getCache = nativeMethod('getNativeCheckCache'); - if (__DEV__ || nativeCheckCacheExhausted || !getCache) { - return undefined; - } - const raw = await getCache(); - if (!raw || typeof raw !== 'string') { - nativeCheckCacheExhausted = true; - return undefined; - } - const entry = JSON.parse(raw); - // Age first: it is the common miss and needs no further parsing. - const ageSeconds = - typeof entry?.ts === 'number' ? Date.now() / 1000 - entry.ts : NaN; - if (!(ageSeconds >= 0 && ageSeconds <= NATIVE_CHECK_CACHE_MAX_AGE_S)) { - nativeCheckCacheExhausted = true; - return undefined; - } - const configJson = this.getNativeConfigJson(); - if ( - typeof entry.body !== 'string' || - typeof entry.request !== 'string' || - typeof entry.config !== 'string' || - !configJson || - !nativeConfigsEquivalent(entry.config, configJson) || - !jsonDeepEqual(JSON.parse(entry.request), requestBody) - ) { - return undefined; - } - const result: unknown = JSON.parse(entry.body); - // Same schema gate as the network path: a native old enough to have - // cached a 200 `{"error": ...}` must not turn it into "no update". - if (!isValidCheckResult(result)) { - return undefined; - } - log('reusing native check response cache'); - return result; - } catch { - return undefined; - } - }; - assertDebug = (matter: string) => { if (__DEV__ && !this.options.debug) { info(this.t('dev_debug_disabled', { matter })); @@ -1030,740 +236,84 @@ export class Pushy { } return true; }; - markSuccess = async () => { - if (sharedState.marked || __DEV__ || !isFirstTime) { - return; - } - // Concurrent callers (auto mark + an explicit call) would all pass the - // check above before the first native call settles: share it. - if (!markingPromise) { - const promise = this.performMarkSuccess().finally(() => { - if (markingPromise === promise) { - markingPromise = undefined; - } - }); - markingPromise = promise; - } - return markingPromise; - }; - private performMarkSuccess = async () => { - try { - await Promise.resolve(PushyModule.markSuccess()); - } catch (e) { - throw this.fail(e, 'MARK_SUCCESS_FAILED', 'errorMarkSuccess'); - } - sharedState.marked = true; - this.report({ type: 'markSuccess' }); - // 救砖回执:这个版本是被救援通道送进来的,且活过了健康确认——遥测里 - // 一条 rescue 回执就是"这台设备被捞回来了"的证据。 - if (currentVersionInfo.forceBootRescue) { - this.report({ type: 'forceBootRescue' }); - } - if (currentVersionInfo.crashRescue) { - this.report({ type: 'crashRescue' }); - } - }; + // --- Reporting (clientReporter) --- + get loggerPromise() { + return this.reporter.loggerPromise; + } + report = (event: ReportEvent) => this.reporter.report(event); + reportInvalidUpdateOnce: Reporter['reportInvalidUpdateOnce'] = (...args) => + this.reporter.reportInvalidUpdateOnce(...args); + captureException: Reporter['captureException'] = (...args) => + this.reporter.captureException(...args); + enableErrorReporting: Reporter['enableErrorReporting'] = (...args) => + this.reporter.enableErrorReporting(...args); + throwIfEnabled = (e: Error) => this.reporter.throwIfEnabled(e); + onError: Reporter['onError'] = (listener) => this.reporter.onError(listener); + wasEmitted = (e: unknown): boolean => this.reporter.wasEmitted(e); + private emitError: Reporter['emitError'] = (...args) => + this.reporter.emitError(...args); + notifyAfterCheckUpdate: Reporter['notifyAfterCheckUpdate'] = (state) => + this.reporter.notifyAfterCheckUpdate(state); + + // --- Checking (clientCheck) --- + get lastChecking() { + return this.checker.lastChecking; + } + set lastChecking(value) { + this.checker.lastChecking = value; + } + get lastRespJson() { + return this.checker.lastRespJson; + } + set lastRespJson(value) { + this.checker.lastRespJson = value; + } + get lastCheckFingerprint() { + return this.checker.lastCheckFingerprint; + } + set lastCheckFingerprint(value) { + this.checker.lastCheckFingerprint = value; + } + getConfiguredCheckEndpoints = () => + this.checker.getConfiguredCheckEndpoints(); + getRemoteEndpoints = () => this.checker.getRemoteEndpoints(); + requestCheckResult: Checker['requestCheckResult'] = (...args) => + this.checker.requestCheckResult(...args); + fetchCheckResult: Checker['fetchCheckResult'] = (fetchPayload) => + this.checker.fetchCheckResult(fetchPayload); + checkUpdate: Checker['checkUpdate'] = (extra) => + this.checker.checkUpdate(extra); + + // --- Downloading (clientDownload) --- + downloadUpdate: Downloader['downloadUpdate'] = (...args) => + this.downloader.downloadUpdate(...args); + downloadAndInstallApk: Downloader['downloadAndInstallApk'] = (...args) => + this.downloader.downloadAndInstallApk(...args); + + // --- Activation (clientActivation) --- + runBeforeReload: Activation['runBeforeReload'] = (context) => + this.activation.runBeforeReload(context); + markSuccess = () => this.activation.markSuccess(); + /** The downloaded version switchVersion(Later) accepts; undefined before a download. */ + getDownloadedHash = (): string | undefined => + this.activation.getDownloadedHash(); /** * Reload into a downloaded version. Resolves true once the native reload * was requested, false when the call was ignored (not the downloaded hash, * a switch already in progress, beforeReload declined, dev without debug). */ - /** The downloaded version switchVersion(Later) accepts; undefined before a download. */ - getDownloadedHash = (): string | undefined => sharedState.downloadedHash; - - switchVersion = async (hash: string): Promise => { - if (!this.assertDebug('switchVersion()')) { - return false; - } - if (!assertHash(hash)) { - return false; - } - if (sharedState.applyingUpdate) { - log(`switchVersion: ${hash} ignored, a switch is already in progress`); - return false; - } - log(`switchVersion: ${hash}`); - const applyingGeneration = beginApplyingUpdate(); - try { - if (!(await this.runBeforeReload({ type: 'switchVersion', hash }))) { - finishApplyingUpdate(applyingGeneration); - return false; - } - } catch (e) { - // Release before emitting: an onError listener may retry right away. - finishApplyingUpdate(applyingGeneration); - // A throw from the user's beforeReload hook is business-code failure, - // not an update-pipeline one: give it a distinct code so telemetry - // excludes it from the server-side patch-health stats. - throw this.fail(e, 'USER_HOOK_ERROR', 'errorSwitchVersion', { - data: { newVersion: hash }, - }); - } - // resetToPackagedBundle may have completed while beforeReload awaited. - if (!ownsApplyingUpdate(applyingGeneration)) { - return false; - } - try { - await PushyModule.reloadUpdate({ hash }); - } catch (e) { - // Do not let a superseded rejection clear a newer switch. - finishApplyingUpdate(applyingGeneration); - throw this.fail(e, 'SWITCH_VERSION_FAILED', 'errorSwitchVersion', { - data: { newVersion: hash }, - }); - } - // A resolved reloadUpdate normally tears this JS context down right away. - // iOS has been seen resolving without restarting (89c638e); a stuck - // applyingUpdate would then silently swallow every later switchVersion, - // so release it once the process has outlived the reload and tell the - // logger (RESTART_FAILED: restart mechanics, not patch health). - armReloadWatchdog(applyingGeneration, () => { - this.report({ - type: 'errorSwitchVersion', - code: 'RESTART_FAILED', - message: 'reloadUpdate resolved but the app did not restart', - data: { newVersion: hash }, - }); - }); - return true; - }; - - switchVersionLater = async (hash: string) => { - if (!this.assertDebug('switchVersionLater()')) { - return; - } - if (assertHash(hash)) { - log(`switchVersionLater: ${hash}`); - try { - return await PushyModule.setNeedUpdate({ hash }); - } catch (e) { - throw this.fail(e, 'SWITCH_VERSION_FAILED', 'errorSwitchVersion', { - data: { newVersion: hash }, - }); - } - } - }; - /** - * The server answers with the root release plus, during a gray rollout, the - * candidate `expVersion`. Which of the two applies is decided per device - * (rollout bucket of this uuid, the running version): every caller of - * checkUpdate gets that decision, not only the provider — a direct - * `client.checkUpdate()` → `downloadUpdate()` would otherwise ignore the - * rollout and install the root release. Idempotent. - */ - private resolveForThisDevice = (result: CheckResult): CheckResult => - result - ? resolveCheckResult( - result, - { - packageVersion: this.getEffectivePackageVersion(), - currentVersion, - uuid: cInfo.uuid, - }, - log - ) - : result; - - checkUpdate = async (extra?: Record) => { - if (!this.assertDebug('checkUpdate()')) { - this.notifyAfterCheckUpdate({ status: 'skipped' }); - return; - } - if (!assertWeb()) { - this.notifyAfterCheckUpdate({ status: 'skipped' }); - return; - } - if ( - this.options.beforeCheckUpdate && - (await this.options.beforeCheckUpdate()) === false - ) { - log('beforeCheckUpdate returned false, skipping check'); - this.notifyAfterCheckUpdate({ status: 'skipped' }); - return; - } - // 内容寻址的二进制身份,服务端据此精确判定 pdiff 适用性(取代 buildTime - // 启发式)。同步读 core 里已预取的值:还没算完就省略字段(服务端回退 - // buildTime 启发式),下一次检查自然带上——绝不为它 await、拖慢或复杂化 - // 检查流程。 - const bundleHash = __DEV__ ? '' : getBundleHash(); - const fetchBody = buildCheckRequestBody({ - packageVersion: this.getEffectivePackageVersion(), - currentVersion, - buildTime, - cInfo, - supportedDiffVersion, - bundleHash, - isDev: __DEV__, - extra, - }); - const stringifyBody = JSON.stringify(fetchBody); - // Identity of this check: only a check with the exact same request (same - // extra / test hash / bundleHash / binary identity) against the same - // endpoint set may share an in-flight or just-settled response. Two - // checks 2s apart with different `extra` are different questions. - const fingerprint = buildCheckFingerprint({ - appKey: this.options.appKey, - endpoints: this.options.server?.main, - queryUrls: this.options.server?.queryUrls, - body: stringifyBody, - }); - const now = Date.now(); - if ( - this.lastRespJson && - this.lastChecking && - this.lastCheckFingerprint === fingerprint && - now - this.lastChecking < 1000 * 5 - ) { - try { - const result = this.resolveForThisDevice(await this.lastRespJson); - this.notifyAfterCheckUpdate({ status: 'completed', result }); - return result; - } catch (e: any) { - // The shared in-flight check failed. Its initiating call reports it - // through emitError/throw; this call must still honor its own - // contract — afterCheckUpdate always fires and throwError applies — - // without double-reporting the same error. - const err = toUpdateError(e, 'CHECK_FAILED'); - this.notifyAfterCheckUpdate({ status: 'error', error: err }); - this.throwIfEnabled(err); - return undefined; - } - } - this.lastChecking = now; - this.lastCheckFingerprint = fingerprint; - // harmony fetch body is not string - let body: any = fetchBody; - if (Platform.OS === 'ios' || Platform.OS === 'android') { - body = stringifyBody; - } - const fetchPayload = { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body, - }; - let respJsonPromise: Promise | undefined; - try { - this.report({ - type: 'checking', - message: `${this.options.appKey}: ${stringifyBody}`, - }); - // The native cold-start check may have a fresh response on disk - // (§10.3); reuse it instead of re-checking. The read happens INSIDE - // the promise so no await lands between the dedup window above and the - // lastRespJson assignment below (the JS2-1 double-send lesson). - respJsonPromise = (async (): Promise => { - // While bundleHash prefetch is still pending, the JS request omits - // that key whereas the native request always includes its synchronously - // computed value. That narrow first-launch window intentionally misses - // the cache rather than delaying checkUpdate for hashing. - const cached = await this.readNativeCheckCache(fetchBody); - return cached ?? (await this.fetchCheckResult(fetchPayload)); - })(); - this.lastRespJson = respJsonPromise; - const rootResult: CheckResult = await respJsonPromise; - - log('checking result:', rootResult); - - if (rootResult?.bundleStatus === 'unknownBundle') { - // 服务端判定当前二进制内嵌 bundle 未注册,增量已被降级为全量。只面向 - // 开发者(日志 + 遥测,控制台聚合是主渠道);终端用户无感知。 - warn( - this.t('warn_unknown_bundle', { - // Each brand ships its own CLI binary (react-native-update-cli). - cli: this.clientType === 'Cresc' ? 'cresc' : 'pushy', - }) - ); - this.report({ - type: 'bundleMismatch', - data: bundleHash ? { bundleHash } : {}, - }); - } - - const result = this.resolveForThisDevice(rootResult); - this.notifyAfterCheckUpdate({ status: 'completed', result }); - this.markJsCheckCompleted(); - return result; - } catch (e: any) { - // A failed check must not keep vouching for an older response: clear - // the dedup slot so the next call re-asks the server — unless a newer - // check already owns the slot, which this failure says nothing about. - if (this.lastRespJson === respJsonPromise) { - this.lastRespJson = undefined; - this.lastCheckFingerprint = undefined; - } - const err = this.fail(e, 'CHECK_FAILED', 'errorChecking', { - fallbackMessage: this.t('error_cannot_connect_server'), - }); - this.notifyAfterCheckUpdate({ status: 'error', error: err }); - this.throwIfEnabled(err); - // No silent fallback to a previous successful response: a stale result - // would let a server outage look like "still updatable" and drive the - // provider's auto-download off it. Return undefined so callers can - // distinguish "check failed" from a real empty result and keep their - // last good updateInfo. - return undefined; - } - }; - downloadUpdate = async ( - updateInfo: CheckResult, - onDownloadProgress?: (data: ProgressData) => void - ) => { - if ( - this.options.beforeDownloadUpdate && - (await this.options.beforeDownloadUpdate(updateInfo)) === false - ) { - log('beforeDownloadUpdate returned false, skipping download'); - return; - } - const decision = decideDownload( - updateInfo, - { currentVersion, rolledBackVersion }, - __DEV__ - ); - if (decision.action === 'none') { - if (decision.reason === 'alreadyCurrent') { - log(`current hash ${currentVersion}, ignored`); - } else if (decision.reason === 'rolledBack') { - log(`rolledback hash ${rolledBackVersion}, ignored`); - } else if (decision.reason === 'noArtifact') { - // A server response that advertises an update but provides no usable - // artifact is a bad release signal, not an ordinary no-update result. - // Keep the user flow silent and report at most once per bad release in - // this process: repeated checks must not inflate download_fail health. - this.reportInvalidUpdateOnce('noArtifact', updateInfo.hash || ''); - } - return; - } - const { hash } = decision; - if (sharedState.downloadedHash === hash) { - log(`duplicated downloaded hash ${sharedState.downloadedHash}, ignored`); - return sharedState.downloadedHash; - } - // Deduplicate concurrent downloads of the same hash regardless of whether a - // progress callback was passed: all callers await the single in-flight - // promise instead of triggering parallel native downloads. - const existingTask = sharedState.downloadingTasks[hash]; - if (existingTask) { - log(`download for hash ${hash} already in progress, reusing it`); - // The second caller's progress callback must still fire. - if (onDownloadProgress) { - sharedState.progressCallbacks[hash]?.add(onDownloadProgress); - } - return existingTask; - } - const task = this.performDownload(updateInfo, decision, onDownloadProgress); - sharedState.downloadingTasks[hash] = task; - try { - return await task; - } finally { - delete sharedState.downloadingTasks[hash]; - } - }; - /** - * Orders candidate mirrors for one artifact: the HEAD-race winner first - * (its post-redirect URL), then the remaining mirrors in configured order. - * Empty when there is nothing to try. - */ - private rankDownloadUrls = async (urls?: string[]): Promise => { - if (!urls?.length) { - return []; - } - const winner = await testUrls(urls); - const ordered: string[] = []; - const push = (url?: string | null) => { - if (url && !ordered.includes(url)) { - ordered.push(url); - } - }; - push(winner); - for (const url of urls) { - push(url); - } - return ordered; - }; - private performDownload = async ( - updateInfo: CheckResult, - plan: DownloadPlan, - onDownloadProgress?: (data: ProgressData) => void - ) => { - const { name, description = '', metaInfo } = updateInfo; - const { hash, attempts, devNoop } = plan; - if (devNoop) { - // Dev without a full package URL: nothing can be fetched, let alone - // installed. Say so and deliver nothing — faking a success here used to - // hand the caller a hash whose switchVersion the native side (rightly) - // rejects with SWITCH_VERSION_FAILED. - log(this.t('dev_incremental_update_disabled')); - return; - } - const patchStartTime = Date.now(); - // One native listener per hash dispatching to a callback set, so - // concurrent callers deduped onto this task can each observe progress - // (they register via downloadUpdate). - const progressCallbacks = new Set<(data: ProgressData) => void>(); - if (onDownloadProgress) { - progressCallbacks.add(onDownloadProgress); - } - sharedState.progressCallbacks[hash] = progressCallbacks; - const dispatchProgress = (data: ProgressData) => { - if (progressCallbacks.size === 0) { - return; - } - const payload = { - ...data, - progress: computeProgress(data.received, data.total), - }; - progressCallbacks.forEach((callback) => { - // One subscriber throwing must not starve the others or abort the - // download: business callbacks are isolated, the error is logged. - try { - callback(payload); - } catch (e) { - warn('onDownloadProgress callback threw', e); - } - }); - }; - // RN >= 0.87 types native event listeners as `(...args: readonly Object[])`, - // which a `(data: ProgressData) => void` callback is not assignable to. Take - // the raw event and narrow it here so this compiles on every RN version. - const onNativeProgress = (event: unknown) => { - const progressData = event as ProgressData; - if (progressData.hash === hash) { - dispatchProgress(progressData); - } - }; - // @ts-expect-error harmony not in existing platforms - const isHarmony = Platform.OS === 'harmony'; - const progressSubscription = ( - isHarmony ? DeviceEventEmitter : pushyNativeEventEmitter - ).addListener('RCTPushyDownloadProgress', onNativeProgress); - const maxRetries = Math.max(0, Math.floor(this.options.maxRetries ?? 3)); - let succeeded = ''; - let lastError: UpdateError | undefined; - const errorMessages: string[] = []; - // A strategy whose bytes arrived but could not be applied fails the same - // way on every retry (same artifact, same base): remember it — with its - // message, so the final report still names it — and skip it on later - // attempts instead of downloading and patching it again. - const exhaustedStrategies = new Map(); - - // The ordered attempts come from decideDownload (the pure decision layer); - // this side only executes them: probe candidate URLs, run the matching - // native download, fall through to the next attempt on failure. - const runners: Record< - DownloadStrategyType, - (url: string) => Promise - > = { - diff: (url) => - PushyModule.downloadPatchFromPpk({ - updateUrl: url, - hash, - originHash: currentVersion, - }), - pdiff: (url) => - PushyModule.downloadPatchFromPackage({ - updateUrl: url, - hash, - }), - full: (url) => - PushyModule.downloadFullUpdate({ - updateUrl: url, - hash, - }), - }; - // Mirror ranking per strategy, kept across retry attempts: the HEAD race - // runs once per strategy per download instead of once per attempt. A - // winner that failed its download is demoted to the back for the next - // attempt (every mirror is tried on every attempt anyway). - const rankedUrls = new Map(); - const errorKeys = { - diff: 'error_diff_failed', - pdiff: 'error_pdiff_failed', - full: 'error_full_patch_failed', - } as const; - - try { - for (let attempt = 0; attempt <= maxRetries; attempt++) { - if (attempt > 0) { - // Exponential backoff with jitter: a bad CDN edge otherwise gets - // every client back at the same 1/2/4s marks. - const backoffMs = Math.round( - Math.min(1000 * 2 ** (attempt - 1), 10000) * - (0.75 + Math.random() * 0.5) - ); - log(`retry attempt ${attempt}/${maxRetries}, waiting ${backoffMs}ms`); - await new Promise((r) => setTimeout(() => r(), backoffMs)); - errorMessages.length = 0; - errorMessages.push(...exhaustedStrategies.values()); - lastError = undefined; - succeeded = ''; - } - this.report({ - type: 'downloading', - data: { - newVersion: hash, - attempt, - }, - }); - for (const { type, urls } of attempts) { - if (succeeded) { - break; - } - if (exhaustedStrategies.has(type)) { - continue; - } - // The HEAD race only ranks mirrors; it is not proof that a mirror can - // serve the whole artifact (CDNs answer HEAD and GET differently, and - // a transfer can die mid-stream). Every mirror of the same artifact - // gets a real download attempt before the strategy is abandoned — - // the native cold-start path already behaves this way. - let orderedUrls = rankedUrls.get(type); - if (!orderedUrls) { - orderedUrls = await this.rankDownloadUrls(urls); - rankedUrls.set(type, orderedUrls); - } - for (const url of orderedUrls) { - log(`downloading ${type} from ${url}`); - try { - await runners[type](url); - succeeded = type; - break; - } catch (e: unknown) { - // The native rejection's stable code (e.g. PATCH_FAILED vs - // DOWNLOAD_FAILED — telemetry classifies on it) may sit on the - // `code` property or, from bridges that cannot set it, as a - // `[CODE] ` message prefix. Either way it survives into the - // i18n-wrapped error together with the original as cause. - const { code, message } = readErrorCode(e); - if (url === orderedUrls[0] && orderedUrls.length > 1) { - rankedUrls.set(type, [...orderedUrls.slice(1), url]); - } - const errorMessage = this.t(errorKeys[type], { - message: message || String(e ?? ''), - }); - errorMessages.push(errorMessage); - lastError = new UpdateError( - errorMessage, - code ?? 'DOWNLOAD_FAILED', - { - cause: e, - } - ); - log(errorMessage); - if (!isMirrorRetryableCode(lastError.code)) { - // The bytes arrived but could not be applied (patch/manifest - // mismatch): every mirror serves the same artifact, so move - // on to the next strategy instead of re-downloading it. - exhaustedStrategies.set(type, errorMessage); - break; - } - } - } - } - if ( - succeeded || - attempts.every(({ type }) => exhaustedStrategies.has(type)) - ) { - // Nothing left that a retry could change. - break; - } - } - } finally { - progressSubscription.remove(); - delete sharedState.progressCallbacks[hash]; - } - // Only a patch-stage failure exhausts a strategy (isMirrorRetryableCode), - // and an exhausted strategy is never retried: these are exactly the patch - // failures, each once. - const patchErrorMessages = [...exhaustedStrategies.values()]; - const patchFailed = patchErrorMessages.length > 0; - if (succeeded && errorMessages.length > 0) { - // An earlier strategy failed and a later one rescued the download - // (e.g. pdiff copiesCrc mismatch on a rebuilt binary → full). Surface - // the degradation: it is invisible to the end user but tells the - // platform that incremental delivery is failing for this binary. - this.report({ - type: 'downloadFallback', - // The patch-health signal (any strategy, in any attempt, failing at - // the patch stage) wins over whatever transport failure happened to - // come last (diff PATCH_FAILED, then pdiff network error, then full - // succeeded is still a patch_fail server-side). - code: patchFailed ? 'PATCH_FAILED' : lastError?.code, - data: { - newVersion: hash, - succeeded, - }, - // Only the patch failures: a mirror's timeout alongside them would - // otherwise be read as the cause server-side. - message: (patchFailed ? patchErrorMessages : errorMessages).join(';'), - }); - } - if (!succeeded) { - // Every attempt runs at least one download (a plan always has an - // artifact URL, devNoop returned above), so a failure left lastError. - throw this.fail(lastError, 'DOWNLOAD_FAILED', 'errorUpdate', { - message: errorMessages.join(';'), - data: { newVersion: hash }, - }); - } - const data: Record = { - newVersion: hash, - diff: succeeded, - duration: Date.now() - patchStartTime, - }; - if (errorMessages.length > 0) { - data.error = errorMessages.join(';'); - } - this.report({ - type: 'downloadSuccess', - data, - }); - log(`downloaded ${succeeded} hash:`, hash); - const hashInfo: Record = { - name, - description, - metaInfo, - }; - if (sharedState.toHash === hash) { - hashInfo.debugChannel = true; - } - // The version is "downloaded" only once its metadata is persisted: a - // lost hash-info write would leave the next launch with a nameless - // version and a switchVersion that the native side may refuse. - try { - await setLocalHashInfo(hash, hashInfo); - } catch (e: any) { - throw this.fail(e, 'FILE_OPERATION_FAILED', 'errorUpdate', { - data: { newVersion: hash }, - }); - } - sharedState.downloadedHash = hash; - return hash; - }; - downloadAndInstallApk = async ( - url: string, - onDownloadProgress?: (data: ProgressData) => void - ) => { - if (Platform.OS !== 'android') { - return; - } - if (sharedState.apkStatus === 'downloading') { - return; - } - if (sharedState.apkStatus === 'downloaded') { - const err = new UpdateError( - this.t('error_apk_pending_install'), - 'APK_INSTALL_PENDING' - ); - this.emitError(err, 'errorInstallApk'); - this.throwIfEnabled(err); - return; - } - sharedState.apkStatus = 'downloading'; - this.report({ type: 'downloadingApk' }); - const progressKey = 'downloadingApk'; - const progressSubscription = onDownloadProgress - ? pushyNativeEventEmitter.addListener( - 'RCTPushyDownloadProgress', - (event: unknown) => { - const progressData = event as ProgressData; - if (progressData.hash === progressKey) { - onDownloadProgress(progressData); - } - } - ) - : undefined; - try { - await PushyModule.downloadAndInstallApk({ - url, - target: 'update.apk', - hash: progressKey, - }); - sharedState.apkStatus = 'downloaded'; - } catch (e) { - sharedState.apkStatus = null; - // Keep the native error (message/stack) instead of discarding it. - this.throwIfEnabled( - this.fail(e, 'APK_DOWNLOAD_FAILED', 'errorDownloadAndInstallApk', { - fallbackMessage: this.t('error_apk_download_failed'), - }) - ); - } finally { - progressSubscription?.remove(); - } - }; - restartApp = async () => { - try { - if (!(await this.runBeforeReload({ type: 'restartApp' }))) { - return; - } - } catch (e) { - throw this.fail(e, 'USER_HOOK_ERROR', 'errorRestart'); - } - try { - return await PushyModule.restartApp(); - } catch (e) { - throw this.fail(e, 'RESTART_FAILED', 'errorRestart'); - } - }; + switchVersion = (hash: string): Promise => + this.activation.switchVersion(hash); + switchVersionLater = (hash: string) => + this.activation.switchVersionLater(hash); + restartApp = () => this.activation.restartApp(); /** - * Reset to the bundle packaged in the binary: wipes every downloaded update - * and the whole update state on the native side, so the app loads the - * built-in bundle on the next launch (or immediately with - * `{ restart: true }`). The client uuid is preserved. - * - * Returns whether the reset actually happened. Like the other update-flow - * APIs it never throws by default — failures land in lastError/onError with - * code RESET_FAILED — but the boolean must not be ignored: a false means the - * app is still running the hot-updated bundle. Set `throwError` to throw. + * Reset to the bundle packaged in the binary (see + * Activation.resetToPackagedBundle). Returns whether the reset happened; + * a false means the app still runs the hot-updated bundle. */ - resetToPackagedBundle = async (options?: { - restart?: boolean; - }): Promise => { - if (!assertWeb()) { - return false; - } - const reset = nativeMethod('resetToPackagedBundle'); - if (!reset) { - const err = new UpdateError( - this.t('error_reset_not_supported'), - 'RESET_FAILED' - ); - this.emitError(err, 'errorReset'); - this.throwIfEnabled(err); - return false; - } - try { - await reset(); - } catch (e) { - this.throwIfEnabled(this.fail(e, 'RESET_FAILED', 'errorReset')); - return false; - } - // The downloaded versions are gone; drop JS bookkeeping referring to them - // so a stale downloadedHash cannot be switched to. - sharedState.downloadedHash = undefined; - sharedState.toHash = undefined; - sharedState.marked = false; - // Invalidate the private owner too: old promises/timers must not touch - // a switch that starts after this reset. - invalidateApplyingUpdate(); - sharedState.apkStatus = null; - this.report({ type: 'reset' }); - if (options?.restart) { - try { - await this.restartApp(); - } catch (e: any) { - // The reset itself succeeded and the restart failure was already - // reported through the pipeline; the boolean must still say "state - // is reset". - log('restart after reset failed:', e?.message || e); - } - } - return true; - }; + resetToPackagedBundle: Activation['resetToPackagedBundle'] = (options) => + this.activation.resetToPackagedBundle(options); } // for international users diff --git a/src/clientActivation.ts b/src/clientActivation.ts new file mode 100644 index 00000000..a6846ba0 --- /dev/null +++ b/src/clientActivation.ts @@ -0,0 +1,323 @@ +import type { Pushy } from './client'; +import type { Reporter } from './clientReporter'; +import type { ProcessState } from './clientState'; +import { + currentVersionInfo, + isFirstTime, + nativeMethod, + PushyModule, +} from './core'; +import { UpdateError } from './error'; +import type { BeforeReloadContext } from './type'; +import { assertWeb, log } from './utils'; + +// How long after a resolved reloadUpdate the process may still be alive before +// the switch is considered not to have restarted the app (see switchVersion). +const RELOAD_WATCHDOG_MS = 5000; + +/** + * What activation needs from the client. Its public members are called + * through the client, so a caller that replaces one on the instance sees + * every use. + */ +export type ActivationHost = Pick< + Pushy, + | 'options' + | 't' + | 'assertDebug' + | 'report' + | 'throwIfEnabled' + | 'runBeforeReload' + | 'restartApp' +>; + +/** + * Everything that changes which version runs: markSuccess, switching to a + * downloaded version (now or next launch), restarting and resetting to the + * packaged bundle. + */ +export class Activation { + constructor( + private readonly host: ActivationHost, + private readonly reporter: Reporter, + private readonly state: ProcessState + ) {} + + private clearReloadWatchdog = () => { + if (this.state.reloadWatchdogTimer !== undefined) { + clearTimeout(this.state.reloadWatchdogTimer); + this.state.reloadWatchdogTimer = undefined; + } + }; + + private beginApplyingUpdate = (): number => { + this.clearReloadWatchdog(); + this.state.shared.applyingUpdate = true; + return ++this.state.applyingUpdateGeneration; + }; + + private ownsApplyingUpdate = (generation: number): boolean => + generation === this.state.applyingUpdateGeneration && + this.state.shared.applyingUpdate; + + private finishApplyingUpdate = (generation: number): boolean => { + if (!this.ownsApplyingUpdate(generation)) { + return false; + } + this.clearReloadWatchdog(); + this.state.shared.applyingUpdate = false; + return true; + }; + + private invalidateApplyingUpdate = () => { + this.state.applyingUpdateGeneration++; + this.clearReloadWatchdog(); + this.state.shared.applyingUpdate = false; + }; + + private armReloadWatchdog = (generation: number, onTimeout: () => void) => { + if (!this.ownsApplyingUpdate(generation)) { + return; + } + this.clearReloadWatchdog(); + const timer = setTimeout(() => { + // A cancelled callback may already be queued; both identities must match. + if ( + this.state.reloadWatchdogTimer !== timer || + !this.ownsApplyingUpdate(generation) + ) { + return; + } + this.state.reloadWatchdogTimer = undefined; + this.state.shared.applyingUpdate = false; + onTimeout(); + }, RELOAD_WATCHDOG_MS); + this.state.reloadWatchdogTimer = timer; + }; + + private assertHash = (hash: string) => { + const { downloadedHash } = this.state.shared; + if (!downloadedHash) { + log(`no downloaded hash yet, ignore switch to ${hash}`); + return; + } + if (hash !== downloadedHash) { + log(`use downloaded hash ${downloadedHash} first`); + return; + } + return true; + }; + + runBeforeReload = async (context: BeforeReloadContext) => { + const { beforeReload } = this.host.options; + if (!beforeReload) { + return true; + } + const shouldReload = await beforeReload(context); + if (shouldReload === false) { + log('beforeReload returned false, skipping reload'); + return false; + } + return true; + }; + + markSuccess = async () => { + if (this.state.shared.marked || __DEV__ || !isFirstTime) { + return; + } + // Concurrent callers (auto mark + an explicit call) would all pass the + // check above before the first native call settles: share it. + if (!this.state.markingPromise) { + const promise = this.performMarkSuccess().finally(() => { + if (this.state.markingPromise === promise) { + this.state.markingPromise = undefined; + } + }); + this.state.markingPromise = promise; + } + return this.state.markingPromise; + }; + + private performMarkSuccess = async () => { + try { + await Promise.resolve(PushyModule.markSuccess()); + } catch (e) { + throw this.reporter.fail(e, 'MARK_SUCCESS_FAILED', 'errorMarkSuccess'); + } + this.state.shared.marked = true; + this.host.report({ type: 'markSuccess' }); + // 救砖回执:这个版本是被救援通道送进来的,且活过了健康确认——遥测里 + // 一条 rescue 回执就是"这台设备被捞回来了"的证据。 + if (currentVersionInfo.forceBootRescue) { + this.host.report({ type: 'forceBootRescue' }); + } + if (currentVersionInfo.crashRescue) { + this.host.report({ type: 'crashRescue' }); + } + }; + + /** The downloaded version switchVersion(Later) accepts; undefined before a download. */ + getDownloadedHash = (): string | undefined => + this.state.shared.downloadedHash; + + /** + * Reload into a downloaded version. Resolves true once the native reload + * was requested, false when the call was ignored (not the downloaded hash, + * a switch already in progress, beforeReload declined, dev without debug). + */ + switchVersion = async (hash: string): Promise => { + const { host } = this; + if (!host.assertDebug('switchVersion()')) { + return false; + } + if (!this.assertHash(hash)) { + return false; + } + if (this.state.shared.applyingUpdate) { + log(`switchVersion: ${hash} ignored, a switch is already in progress`); + return false; + } + log(`switchVersion: ${hash}`); + const applyingGeneration = this.beginApplyingUpdate(); + try { + if (!(await this.host.runBeforeReload({ type: 'switchVersion', hash }))) { + this.finishApplyingUpdate(applyingGeneration); + return false; + } + } catch (e) { + // Release before emitting: an onError listener may retry right away. + this.finishApplyingUpdate(applyingGeneration); + // A throw from the user's beforeReload hook is business-code failure, + // not an update-pipeline one: give it a distinct code so telemetry + // excludes it from the server-side patch-health stats. + throw this.reporter.fail(e, 'USER_HOOK_ERROR', 'errorSwitchVersion', { + data: { newVersion: hash }, + }); + } + // resetToPackagedBundle may have completed while beforeReload awaited. + if (!this.ownsApplyingUpdate(applyingGeneration)) { + return false; + } + try { + await PushyModule.reloadUpdate({ hash }); + } catch (e) { + // Do not let a superseded rejection clear a newer switch. + this.finishApplyingUpdate(applyingGeneration); + throw this.reporter.fail( + e, + 'SWITCH_VERSION_FAILED', + 'errorSwitchVersion', + { + data: { newVersion: hash }, + } + ); + } + // A resolved reloadUpdate normally tears this JS context down right away. + // iOS has been seen resolving without restarting (89c638e); a stuck + // applyingUpdate would then silently swallow every later switchVersion, + // so release it once the process has outlived the reload and tell the + // logger (RESTART_FAILED: restart mechanics, not patch health). + this.armReloadWatchdog(applyingGeneration, () => { + host.report({ + type: 'errorSwitchVersion', + code: 'RESTART_FAILED', + message: 'reloadUpdate resolved but the app did not restart', + data: { newVersion: hash }, + }); + }); + return true; + }; + + switchVersionLater = async (hash: string) => { + if (!this.host.assertDebug('switchVersionLater()')) { + return; + } + if (this.assertHash(hash)) { + log(`switchVersionLater: ${hash}`); + try { + return await PushyModule.setNeedUpdate({ hash }); + } catch (e) { + throw this.reporter.fail( + e, + 'SWITCH_VERSION_FAILED', + 'errorSwitchVersion', + { + data: { newVersion: hash }, + } + ); + } + } + }; + + restartApp = async () => { + try { + if (!(await this.host.runBeforeReload({ type: 'restartApp' }))) { + return; + } + } catch (e) { + throw this.reporter.fail(e, 'USER_HOOK_ERROR', 'errorRestart'); + } + try { + return await PushyModule.restartApp(); + } catch (e) { + throw this.reporter.fail(e, 'RESTART_FAILED', 'errorRestart'); + } + }; + + /** + * Reset to the bundle packaged in the binary: wipes every downloaded update + * and the whole update state on the native side, so the app loads the + * built-in bundle on the next launch (or immediately with + * `{ restart: true }`). The client uuid is preserved. + * + * Returns whether the reset actually happened. Like the other update-flow + * APIs it never throws by default — failures land in lastError/onError with + * code RESET_FAILED — but the boolean must not be ignored: a false means the + * app is still running the hot-updated bundle. Set `throwError` to throw. + */ + resetToPackagedBundle = async (options?: { + restart?: boolean; + }): Promise => { + const { host } = this; + if (!assertWeb()) { + return false; + } + const reset = nativeMethod('resetToPackagedBundle'); + if (!reset) { + const err = new UpdateError( + host.t('error_reset_not_supported'), + 'RESET_FAILED' + ); + this.reporter.emitError(err, 'errorReset'); + host.throwIfEnabled(err); + return false; + } + try { + await reset(); + } catch (e) { + host.throwIfEnabled(this.reporter.fail(e, 'RESET_FAILED', 'errorReset')); + return false; + } + // The downloaded versions are gone; drop JS bookkeeping referring to them + // so a stale downloadedHash cannot be switched to. + this.state.shared.downloadedHash = undefined; + this.state.shared.toHash = undefined; + this.state.shared.marked = false; + // Invalidate the private owner too: old promises/timers must not touch + // a switch that starts after this reset. + this.invalidateApplyingUpdate(); + this.state.shared.apkStatus = null; + host.report({ type: 'reset' }); + if (options?.restart) { + try { + await this.host.restartApp(); + } catch (e: any) { + // The reset itself succeeded and the restart failure was already + // reported through the pipeline; the boolean must still say "state + // is reset". + log('restart after reset failed:', e?.message || e); + } + } + return true; + }; +} diff --git a/src/clientCheck.ts b/src/clientCheck.ts new file mode 100644 index 00000000..e318bc5d --- /dev/null +++ b/src/clientCheck.ts @@ -0,0 +1,405 @@ +import { Platform } from 'react-native'; +import type { Pushy } from './client'; +import type { NativeConfigSync } from './clientNativeConfig'; +import type { Reporter } from './clientReporter'; +import type { ProcessState } from './clientState'; +import { + buildTime, + cInfo, + currentVersion, + getBundleHash, + nativeMethod, + supportedDiffVersion, +} from './core'; +import { executeEndpointFallback } from './endpoint'; +import { toUpdateError, UpdateError } from './error'; +import type { CheckResult } from './type'; +import { + buildCheckFingerprint, + buildCheckRequestBody, + dedupeEndpoints, + isValidCheckResult, + jsonDeepEqual, + nativeConfigsEquivalent, + resolveCheckResult, +} from './updateFlowCore'; +import { + assertWeb, + DEFAULT_FETCH_TIMEOUT_MS, + fetchWithTimeout, + log, + promiseAny, + warn, +} from './utils'; + +// Non-2xx check responses are usually HTML error pages; keep only the head +// for the error message (it can end up in an alert) and the logger. +const MAX_HTTP_ERROR_TEXT = 200; + +const NATIVE_CHECK_CACHE_MAX_AGE_S = 120; + +/** + * What checking needs from the client. Its public members are called through + * the client, so a caller that replaces one on the instance sees every use. + */ +export type CheckHost = Pick< + Pushy, + | 'options' + | 'clientType' + | 't' + | 'assertDebug' + | 'getEffectivePackageVersion' + | 'report' + | 'notifyAfterCheckUpdate' + | 'throwIfEnabled' + | 'getConfiguredCheckEndpoints' + | 'getRemoteEndpoints' + | 'requestCheckResult' + | 'fetchCheckResult' +>; + +/** + * The checkUpdate flow: request identity and dedup, the native response + * cache, the endpoint fallback, the per-device rollout decision. + */ +export class Checker { + constructor( + private readonly host: CheckHost, + private readonly reporter: Reporter, + private readonly nativeConfig: NativeConfigSync, + private readonly state: ProcessState + ) {} + + lastChecking?: number; + lastRespJson?: Promise; + lastCheckFingerprint?: string; + // Endpoint that most recently served a successful checkUpdate; telemetry + // reuses it instead of re-running the fallback race. + lastWorkingEndpoint?: string; + + // server.main is deduplicated on assignment (cloneServerConfig), and the + // endpoint plan dedupes again anyway. + getConfiguredCheckEndpoints = () => this.host.options.server?.main ?? []; + + getRemoteEndpoints = async () => { + const { server } = this.host.options; + if (!server?.queryUrls?.length) { + return []; + } + try { + // Race the parsed lists, not the raw responses: fetch resolves for + // 404/403/5xx and anti-bot HTML too, and this path only runs once the + // main endpoints are already failing — a mirror that answers first + // with an error page must not beat the healthy one. + const remoteEndpoints = await promiseAny( + server.queryUrls.map(async (queryUrl) => { + const resp = await fetchWithTimeout(queryUrl, {}); + if (!resp.ok) { + throw Error(`${queryUrl}: ${resp.status}`); + } + const list: unknown = await resp.json(); + if (!Array.isArray(list)) { + throw Error(`${queryUrl}: not an endpoint list`); + } + return list; + }) + ); + log('fetch endpoints:', remoteEndpoints); + // Configured and already-failed endpoints are dropped by the endpoint + // fallback itself. + return dedupeEndpoints( + remoteEndpoints.filter( + // The list is fetched from a public mirror; it may add endpoints + // but never move the check outside TLS. + (endpoint): endpoint is string => + typeof endpoint === 'string' && /^https:\/\//i.test(endpoint) + ) + ); + } catch (e) { + log('failed to fetch endpoints from: ', server.queryUrls, e); + } + return []; + }; + + requestCheckResult = async ( + endpoint: string, + fetchPayload: Parameters[1], + signal?: AbortSignal + ): Promise => { + try { + const resp = await fetchWithTimeout( + `${endpoint}/checkUpdate/${this.host.options.appKey}`, + fetchPayload, + DEFAULT_FETCH_TIMEOUT_MS, + signal + ); + + if (!resp.ok) { + const respText = (await resp.text()).slice(0, MAX_HTTP_ERROR_TEXT); + throw new UpdateError( + this.host.t('error_http_status', { + status: resp.status, + statusText: respText, + }), + 'HTTP_STATUS', + { extra: { status: resp.status } } + ); + } + + const result: unknown = await resp.json(); + if (!isValidCheckResult(result)) { + // A 2xx that is not a verdict (`{"error": ...}`, a captive-portal + // page that parsed, ...) is a failed endpoint: throwing keeps the + // fallback moving to the next one instead of treating it as "no + // update". + throw new UpdateError( + this.host.t('error_invalid_check_response'), + 'INVALID_RESPONSE' + ); + } + return result; + } catch (e) { + log('check endpoint failed', endpoint, e); + throw e; + } + }; + + fetchCheckResult = async (fetchPayload: Parameters[1]) => { + const { endpoint, value } = await executeEndpointFallback({ + configuredEndpoints: this.host.getConfiguredCheckEndpoints(), + getRemoteEndpoints: this.host.getRemoteEndpoints, + tryEndpoint: (currentEndpoint, signal) => + this.host.requestCheckResult(currentEndpoint, fetchPayload, signal), + onFirstFailure: ({ error }) => { + this.host.report({ + type: 'errorChecking', + message: this.host.t('error_cannot_connect_backup', { + message: error.message, + }), + }); + }, + }); + + log('check endpoint success', endpoint); + this.lastWorkingEndpoint = endpoint; + return value; + }; + + /** + * Reuse the native cold-start check's cached response when fresh + * (NATIVE_CHECKUPDATE_DESIGN §10.3) instead of re-checking. Returns + * undefined whenever the cache is absent, stale, or unreadable — any + * failure falls through to a normal network check. + */ + private readNativeCheckCache = async ( + requestBody: Record + ): Promise => { + try { + const getCache = nativeMethod('getNativeCheckCache'); + if (__DEV__ || this.state.nativeCheckCacheExhausted || !getCache) { + return undefined; + } + const raw = await getCache(); + if (!raw || typeof raw !== 'string') { + this.state.nativeCheckCacheExhausted = true; + return undefined; + } + const entry = JSON.parse(raw); + // Age first: it is the common miss and needs no further parsing. + const ageSeconds = + typeof entry?.ts === 'number' ? Date.now() / 1000 - entry.ts : NaN; + if (!(ageSeconds >= 0 && ageSeconds <= NATIVE_CHECK_CACHE_MAX_AGE_S)) { + this.state.nativeCheckCacheExhausted = true; + return undefined; + } + const configJson = this.nativeConfig.getNativeConfigJson(); + if ( + typeof entry.body !== 'string' || + typeof entry.request !== 'string' || + typeof entry.config !== 'string' || + !configJson || + !nativeConfigsEquivalent(entry.config, configJson) || + !jsonDeepEqual(JSON.parse(entry.request), requestBody) + ) { + return undefined; + } + const result: unknown = JSON.parse(entry.body); + // Same schema gate as the network path: a native old enough to have + // cached a 200 `{"error": ...}` must not turn it into "no update". + if (!isValidCheckResult(result)) { + return undefined; + } + log('reusing native check response cache'); + return result; + } catch { + return undefined; + } + }; + + /** + * The server answers with the root release plus, during a gray rollout, the + * candidate `expVersion`. Which of the two applies is decided per device + * (rollout bucket of this uuid, the running version): every caller of + * checkUpdate gets that decision, not only the provider — a direct + * `client.checkUpdate()` → `downloadUpdate()` would otherwise ignore the + * rollout and install the root release. Idempotent. + */ + private resolveForThisDevice = (result: CheckResult): CheckResult => + result + ? resolveCheckResult( + result, + { + packageVersion: this.host.getEffectivePackageVersion(), + currentVersion, + uuid: cInfo.uuid, + }, + log + ) + : result; + + checkUpdate = async (extra?: Record) => { + const { host } = this; + const { options } = host; + if (!host.assertDebug('checkUpdate()')) { + host.notifyAfterCheckUpdate({ status: 'skipped' }); + return; + } + if (!assertWeb()) { + host.notifyAfterCheckUpdate({ status: 'skipped' }); + return; + } + if ( + options.beforeCheckUpdate && + (await options.beforeCheckUpdate()) === false + ) { + log('beforeCheckUpdate returned false, skipping check'); + host.notifyAfterCheckUpdate({ status: 'skipped' }); + return; + } + // 内容寻址的二进制身份,服务端据此精确判定 pdiff 适用性(取代 buildTime + // 启发式)。同步读 core 里已预取的值:还没算完就省略字段(服务端回退 + // buildTime 启发式),下一次检查自然带上——绝不为它 await、拖慢或复杂化 + // 检查流程。 + const bundleHash = __DEV__ ? '' : getBundleHash(); + const fetchBody = buildCheckRequestBody({ + packageVersion: host.getEffectivePackageVersion(), + currentVersion, + buildTime, + cInfo, + supportedDiffVersion, + bundleHash, + isDev: __DEV__, + extra, + }); + const stringifyBody = JSON.stringify(fetchBody); + // Identity of this check: only a check with the exact same request (same + // extra / test hash / bundleHash / binary identity) against the same + // endpoint set may share an in-flight or just-settled response. Two + // checks 2s apart with different `extra` are different questions. + const fingerprint = buildCheckFingerprint({ + appKey: options.appKey, + endpoints: options.server?.main, + queryUrls: options.server?.queryUrls, + body: stringifyBody, + }); + const now = Date.now(); + if ( + this.lastRespJson && + this.lastChecking && + this.lastCheckFingerprint === fingerprint && + now - this.lastChecking < 1000 * 5 + ) { + try { + const result = this.resolveForThisDevice(await this.lastRespJson); + host.notifyAfterCheckUpdate({ status: 'completed', result }); + return result; + } catch (e: any) { + // The shared in-flight check failed. Its initiating call reports it + // through emitError/throw; this call must still honor its own + // contract — afterCheckUpdate always fires and throwError applies — + // without double-reporting the same error. + const err = toUpdateError(e, 'CHECK_FAILED'); + host.notifyAfterCheckUpdate({ status: 'error', error: err }); + host.throwIfEnabled(err); + return undefined; + } + } + this.lastChecking = now; + this.lastCheckFingerprint = fingerprint; + // harmony fetch body is not string + let body: any = fetchBody; + if (Platform.OS === 'ios' || Platform.OS === 'android') { + body = stringifyBody; + } + const fetchPayload = { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body, + }; + let respJsonPromise: Promise | undefined; + try { + host.report({ + type: 'checking', + message: `${options.appKey}: ${stringifyBody}`, + }); + // The native cold-start check may have a fresh response on disk + // (§10.3); reuse it instead of re-checking. The read happens INSIDE + // the promise so no await lands between the dedup window above and the + // lastRespJson assignment below (the JS2-1 double-send lesson). + respJsonPromise = (async (): Promise => { + // While bundleHash prefetch is still pending, the JS request omits + // that key whereas the native request always includes its synchronously + // computed value. That narrow first-launch window intentionally misses + // the cache rather than delaying checkUpdate for hashing. + const cached = await this.readNativeCheckCache(fetchBody); + return cached ?? (await host.fetchCheckResult(fetchPayload)); + })(); + this.lastRespJson = respJsonPromise; + const rootResult: CheckResult = await respJsonPromise; + + log('checking result:', rootResult); + + if (rootResult?.bundleStatus === 'unknownBundle') { + // 服务端判定当前二进制内嵌 bundle 未注册,增量已被降级为全量。只面向 + // 开发者(日志 + 遥测,控制台聚合是主渠道);终端用户无感知。 + warn( + host.t('warn_unknown_bundle', { + // Each brand ships its own CLI binary (react-native-update-cli). + cli: host.clientType === 'Cresc' ? 'cresc' : 'pushy', + }) + ); + host.report({ + type: 'bundleMismatch', + data: bundleHash ? { bundleHash } : {}, + }); + } + + const result = this.resolveForThisDevice(rootResult); + host.notifyAfterCheckUpdate({ status: 'completed', result }); + this.nativeConfig.markJsCheckCompleted(); + return result; + } catch (e: any) { + // A failed check must not keep vouching for an older response: clear + // the dedup slot so the next call re-asks the server — unless a newer + // check already owns the slot, which this failure says nothing about. + if (this.lastRespJson === respJsonPromise) { + this.lastRespJson = undefined; + this.lastCheckFingerprint = undefined; + } + const err = this.reporter.fail(e, 'CHECK_FAILED', 'errorChecking', { + fallbackMessage: host.t('error_cannot_connect_server'), + }); + host.notifyAfterCheckUpdate({ status: 'error', error: err }); + host.throwIfEnabled(err); + // No silent fallback to a previous successful response: a stale result + // would let a server outage look like "still updatable" and drive the + // provider's auto-download off it. Return undefined so callers can + // distinguish "check failed" from a real empty result and keep their + // last good updateInfo. + return undefined; + } + }; +} diff --git a/src/clientDownload.ts b/src/clientDownload.ts new file mode 100644 index 00000000..ff62e89e --- /dev/null +++ b/src/clientDownload.ts @@ -0,0 +1,448 @@ +import { DeviceEventEmitter, Platform } from 'react-native'; +import type { Pushy } from './client'; +import type { Reporter } from './clientReporter'; +import type { ProcessState } from './clientState'; +import { + currentVersion, + PushyModule, + pushyNativeEventEmitter, + rolledBackVersion, + setLocalHashInfo, +} from './core'; +import { readErrorCode, UpdateError } from './error'; +import type { CheckResult, ProgressData } from './type'; +import { + type DownloadPlan, + type DownloadStrategyType, + decideDownload, + isMirrorRetryableCode, +} from './updateFlowCore'; +import { computeProgress, log, testUrls, warn } from './utils'; + +/** + * What downloading needs from the client. Its public members are called + * through the client, so a caller that replaces one on the instance sees + * every use. + */ +export type DownloadHost = Pick< + Pushy, + 'options' | 't' | 'report' | 'reportInvalidUpdateOnce' | 'throwIfEnabled' +>; + +/** + * Downloads: the per-device plan from decideDownload executed over the + * native downloaders, with mirror ranking, retries and progress fan-out; + * plus the Android full-package (APK) path. + */ +export class Downloader { + constructor( + private readonly host: DownloadHost, + private readonly reporter: Reporter, + private readonly state: ProcessState + ) {} + + downloadUpdate = async ( + updateInfo: CheckResult, + onDownloadProgress?: (data: ProgressData) => void + ) => { + const { options } = this.host; + if ( + options.beforeDownloadUpdate && + (await options.beforeDownloadUpdate(updateInfo)) === false + ) { + log('beforeDownloadUpdate returned false, skipping download'); + return; + } + const decision = decideDownload( + updateInfo, + { currentVersion, rolledBackVersion }, + __DEV__ + ); + if (decision.action === 'none') { + if (decision.reason === 'alreadyCurrent') { + log(`current hash ${currentVersion}, ignored`); + } else if (decision.reason === 'rolledBack') { + log(`rolledback hash ${rolledBackVersion}, ignored`); + } else if (decision.reason === 'noArtifact') { + // A server response that advertises an update but provides no usable + // artifact is a bad release signal, not an ordinary no-update result. + // Keep the user flow silent and report at most once per bad release in + // this process: repeated checks must not inflate download_fail health. + this.host.reportInvalidUpdateOnce('noArtifact', updateInfo.hash || ''); + } + return; + } + const { hash } = decision; + if (this.state.shared.downloadedHash === hash) { + log( + `duplicated downloaded hash ${this.state.shared.downloadedHash}, ignored` + ); + return this.state.shared.downloadedHash; + } + // Deduplicate concurrent downloads of the same hash regardless of whether a + // progress callback was passed: all callers await the single in-flight + // promise instead of triggering parallel native downloads. + const existingTask = this.state.shared.downloadingTasks[hash]; + if (existingTask) { + log(`download for hash ${hash} already in progress, reusing it`); + // The second caller's progress callback must still fire. + if (onDownloadProgress) { + this.state.shared.progressCallbacks[hash]?.add(onDownloadProgress); + } + return existingTask; + } + const task = this.performDownload(updateInfo, decision, onDownloadProgress); + this.state.shared.downloadingTasks[hash] = task; + try { + return await task; + } finally { + delete this.state.shared.downloadingTasks[hash]; + } + }; + + /** + * Orders candidate mirrors for one artifact: the HEAD-race winner first + * (its post-redirect URL), then the remaining mirrors in configured order. + * Empty when there is nothing to try. + */ + private rankDownloadUrls = async (urls?: string[]): Promise => { + if (!urls?.length) { + return []; + } + const winner = await testUrls(urls); + const ordered: string[] = []; + const push = (url?: string | null) => { + if (url && !ordered.includes(url)) { + ordered.push(url); + } + }; + push(winner); + for (const url of urls) { + push(url); + } + return ordered; + }; + + private performDownload = async ( + updateInfo: CheckResult, + plan: DownloadPlan, + onDownloadProgress?: (data: ProgressData) => void + ) => { + const { host } = this; + const { name, description = '', metaInfo } = updateInfo; + const { hash, attempts, devNoop } = plan; + if (devNoop) { + // Dev without a full package URL: nothing can be fetched, let alone + // installed. Say so and deliver nothing — faking a success here used to + // hand the caller a hash whose switchVersion the native side (rightly) + // rejects with SWITCH_VERSION_FAILED. + log(this.host.t('dev_incremental_update_disabled')); + return; + } + const patchStartTime = Date.now(); + // One native listener per hash dispatching to a callback set, so + // concurrent callers deduped onto this task can each observe progress + // (they register via downloadUpdate). + const progressCallbacks = new Set<(data: ProgressData) => void>(); + if (onDownloadProgress) { + progressCallbacks.add(onDownloadProgress); + } + this.state.shared.progressCallbacks[hash] = progressCallbacks; + const dispatchProgress = (data: ProgressData) => { + if (progressCallbacks.size === 0) { + return; + } + const payload = { + ...data, + progress: computeProgress(data.received, data.total), + }; + progressCallbacks.forEach((callback) => { + // One subscriber throwing must not starve the others or abort the + // download: business callbacks are isolated, the error is logged. + try { + callback(payload); + } catch (e) { + warn('onDownloadProgress callback threw', e); + } + }); + }; + // RN >= 0.87 types native event listeners as `(...args: readonly Object[])`, + // which a `(data: ProgressData) => void` callback is not assignable to. Take + // the raw event and narrow it here so this compiles on every RN version. + const onNativeProgress = (event: unknown) => { + const progressData = event as ProgressData; + if (progressData.hash === hash) { + dispatchProgress(progressData); + } + }; + // @ts-expect-error harmony not in existing platforms + const isHarmony = Platform.OS === 'harmony'; + const progressSubscription = ( + isHarmony ? DeviceEventEmitter : pushyNativeEventEmitter + ).addListener('RCTPushyDownloadProgress', onNativeProgress); + const maxRetries = Math.max( + 0, + Math.floor(this.host.options.maxRetries ?? 3) + ); + let succeeded = ''; + let lastError: UpdateError | undefined; + const errorMessages: string[] = []; + // A strategy whose bytes arrived but could not be applied fails the same + // way on every retry (same artifact, same base): remember it — with its + // message, so the final report still names it — and skip it on later + // attempts instead of downloading and patching it again. + const exhaustedStrategies = new Map(); + + // The ordered attempts come from decideDownload (the pure decision layer); + // this side only executes them: probe candidate URLs, run the matching + // native download, fall through to the next attempt on failure. + const runners: Record< + DownloadStrategyType, + (url: string) => Promise + > = { + diff: (url) => + PushyModule.downloadPatchFromPpk({ + updateUrl: url, + hash, + originHash: currentVersion, + }), + pdiff: (url) => + PushyModule.downloadPatchFromPackage({ + updateUrl: url, + hash, + }), + full: (url) => + PushyModule.downloadFullUpdate({ + updateUrl: url, + hash, + }), + }; + // Mirror ranking per strategy, kept across retry attempts: the HEAD race + // runs once per strategy per download instead of once per attempt. A + // winner that failed its download is demoted to the back for the next + // attempt (every mirror is tried on every attempt anyway). + const rankedUrls = new Map(); + const errorKeys = { + diff: 'error_diff_failed', + pdiff: 'error_pdiff_failed', + full: 'error_full_patch_failed', + } as const; + + try { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + // Exponential backoff with jitter: a bad CDN edge otherwise gets + // every client back at the same 1/2/4s marks. + const backoffMs = Math.round( + Math.min(1000 * 2 ** (attempt - 1), 10000) * + (0.75 + Math.random() * 0.5) + ); + log(`retry attempt ${attempt}/${maxRetries}, waiting ${backoffMs}ms`); + await new Promise((r) => setTimeout(() => r(), backoffMs)); + errorMessages.length = 0; + errorMessages.push(...exhaustedStrategies.values()); + lastError = undefined; + succeeded = ''; + } + host.report({ + type: 'downloading', + data: { + newVersion: hash, + attempt, + }, + }); + for (const { type, urls } of attempts) { + if (succeeded) { + break; + } + if (exhaustedStrategies.has(type)) { + continue; + } + // The HEAD race only ranks mirrors; it is not proof that a mirror can + // serve the whole artifact (CDNs answer HEAD and GET differently, and + // a transfer can die mid-stream). Every mirror of the same artifact + // gets a real download attempt before the strategy is abandoned — + // the native cold-start path already behaves this way. + let orderedUrls = rankedUrls.get(type); + if (!orderedUrls) { + orderedUrls = await this.rankDownloadUrls(urls); + rankedUrls.set(type, orderedUrls); + } + for (const url of orderedUrls) { + log(`downloading ${type} from ${url}`); + try { + await runners[type](url); + succeeded = type; + break; + } catch (e: unknown) { + // The native rejection's stable code (e.g. PATCH_FAILED vs + // DOWNLOAD_FAILED — telemetry classifies on it) may sit on the + // `code` property or, from bridges that cannot set it, as a + // `[CODE] ` message prefix. Either way it survives into the + // i18n-wrapped error together with the original as cause. + const { code, message } = readErrorCode(e); + if (url === orderedUrls[0] && orderedUrls.length > 1) { + rankedUrls.set(type, [...orderedUrls.slice(1), url]); + } + const errorMessage = this.host.t(errorKeys[type], { + message: message || String(e ?? ''), + }); + errorMessages.push(errorMessage); + lastError = new UpdateError( + errorMessage, + code ?? 'DOWNLOAD_FAILED', + { + cause: e, + } + ); + log(errorMessage); + if (!isMirrorRetryableCode(lastError.code)) { + // The bytes arrived but could not be applied (patch/manifest + // mismatch): every mirror serves the same artifact, so move + // on to the next strategy instead of re-downloading it. + exhaustedStrategies.set(type, errorMessage); + break; + } + } + } + } + if ( + succeeded || + attempts.every(({ type }) => exhaustedStrategies.has(type)) + ) { + // Nothing left that a retry could change. + break; + } + } + } finally { + progressSubscription.remove(); + delete this.state.shared.progressCallbacks[hash]; + } + // Only a patch-stage failure exhausts a strategy (isMirrorRetryableCode), + // and an exhausted strategy is never retried: these are exactly the patch + // failures, each once. + const patchErrorMessages = [...exhaustedStrategies.values()]; + const patchFailed = patchErrorMessages.length > 0; + if (succeeded && errorMessages.length > 0) { + // An earlier strategy failed and a later one rescued the download + // (e.g. pdiff copiesCrc mismatch on a rebuilt binary → full). Surface + // the degradation: it is invisible to the end user but tells the + // platform that incremental delivery is failing for this binary. + host.report({ + type: 'downloadFallback', + // The patch-health signal (any strategy, in any attempt, failing at + // the patch stage) wins over whatever transport failure happened to + // come last (diff PATCH_FAILED, then pdiff network error, then full + // succeeded is still a patch_fail server-side). + code: patchFailed ? 'PATCH_FAILED' : lastError?.code, + data: { + newVersion: hash, + succeeded, + }, + // Only the patch failures: a mirror's timeout alongside them would + // otherwise be read as the cause server-side. + message: (patchFailed ? patchErrorMessages : errorMessages).join(';'), + }); + } + if (!succeeded) { + // Every attempt runs at least one download (a plan always has an + // artifact URL, devNoop returned above), so a failure left lastError. + throw this.reporter.fail(lastError, 'DOWNLOAD_FAILED', 'errorUpdate', { + message: errorMessages.join(';'), + data: { newVersion: hash }, + }); + } + const data: Record = { + newVersion: hash, + diff: succeeded, + duration: Date.now() - patchStartTime, + }; + if (errorMessages.length > 0) { + data.error = errorMessages.join(';'); + } + host.report({ + type: 'downloadSuccess', + data, + }); + log(`downloaded ${succeeded} hash:`, hash); + const hashInfo: Record = { + name, + description, + metaInfo, + }; + if (this.state.shared.toHash === hash) { + hashInfo.debugChannel = true; + } + // The version is "downloaded" only once its metadata is persisted: a + // lost hash-info write would leave the next launch with a nameless + // version and a switchVersion that the native side may refuse. + try { + await setLocalHashInfo(hash, hashInfo); + } catch (e: any) { + throw this.reporter.fail(e, 'FILE_OPERATION_FAILED', 'errorUpdate', { + data: { newVersion: hash }, + }); + } + this.state.shared.downloadedHash = hash; + return hash; + }; + + downloadAndInstallApk = async ( + url: string, + onDownloadProgress?: (data: ProgressData) => void + ) => { + const { host } = this; + if (Platform.OS !== 'android') { + return; + } + if (this.state.shared.apkStatus === 'downloading') { + return; + } + if (this.state.shared.apkStatus === 'downloaded') { + const err = new UpdateError( + this.host.t('error_apk_pending_install'), + 'APK_INSTALL_PENDING' + ); + this.reporter.emitError(err, 'errorInstallApk'); + host.throwIfEnabled(err); + return; + } + this.state.shared.apkStatus = 'downloading'; + host.report({ type: 'downloadingApk' }); + const progressKey = 'downloadingApk'; + const progressSubscription = onDownloadProgress + ? pushyNativeEventEmitter.addListener( + 'RCTPushyDownloadProgress', + (event: unknown) => { + const progressData = event as ProgressData; + if (progressData.hash === progressKey) { + onDownloadProgress(progressData); + } + } + ) + : undefined; + try { + await PushyModule.downloadAndInstallApk({ + url, + target: 'update.apk', + hash: progressKey, + }); + this.state.shared.apkStatus = 'downloaded'; + } catch (e) { + this.state.shared.apkStatus = null; + // Keep the native error (message/stack) instead of discarding it. + host.throwIfEnabled( + this.reporter.fail( + e, + 'APK_DOWNLOAD_FAILED', + 'errorDownloadAndInstallApk', + { + fallbackMessage: this.host.t('error_apk_download_failed'), + } + ) + ); + } finally { + progressSubscription?.remove(); + } + }; +} diff --git a/src/clientNativeConfig.ts b/src/clientNativeConfig.ts new file mode 100644 index 00000000..fe02c1d2 --- /dev/null +++ b/src/clientNativeConfig.ts @@ -0,0 +1,171 @@ +import { cInfo, nativeMethod } from './core'; +import type { ClientOptions } from './type'; +import { log } from './utils'; + +/** What the native config sync needs from the client. */ +export interface NativeConfigHost { + readonly options: ClientOptions; + getEffectivePackageVersion(): string; +} + +/** + * Keeps the config the native cold-start check runs with + * (NATIVE_CHECKUPDATE_DESIGN §10.1) in step with the client options, and + * tells native when JS already holds a check response for it (§10.3). + */ +export class NativeConfigSync { + constructor(private readonly host: NativeConfigHost) {} + + private syncedNativeConfigJson?: string; + private pendingNativeConfigJson?: string; + private nativeConfigSyncInFlight = false; + // Config last acknowledged by markJsCheckCompleted. + private jsCheckCompletedConfigJson?: string; + + /** Build the subset of options used by the native cold-start check. */ + private getNativeConfig = (): Record | undefined => { + if (!nativeMethod('syncNativeConfig')) { + // Web, or an older native without the native check. + return undefined; + } + const { + appKey, + server, + updateStrategy, + checkStrategy, + disableNativeCheck, + } = this.host.options; + if (disableNativeCheck) { + // Explicit opt-out: keep the identity fields so the persisted config + // still describes this app, and let the orchestrators bail on `disabled` + // before any IO. + return { disabled: true, appKey, rnu: cInfo.rnu, rn: cInfo.rn }; + } + if (!appKey || !server?.main?.length) { + // Unusable rather than merely absent: the JS check would fail on these + // options too (NO_ENDPOINTS / APPKEY_REQUIRED). The caller turns this + // into an explicit disabled state. + return undefined; + } + // An app that turned automatic checks off (checkStrategy: null) must not + // be handed a version switch it never asked for. The cold-start check + // still runs and still downloads — that is what keeps a bricked device + // rescuable — but activation waits for the JS side, or for the server's + // explicit per-version forceBoot directive (shouldActivateAfterDownload). + const autoCheckEnabled = checkStrategy != null; + return { + appKey, + packageVersion: this.host.getEffectivePackageVersion(), + endpoints: server.main, + queryUrls: server.queryUrls ?? [], + // The native check may activate a downloaded version (next launch) + // only under the silent strategies; alert-style strategies keep + // activation with the JS side (§6/§10.1). + afterDownload: + autoCheckEnabled && + (updateStrategy === 'silentAndNow' || + updateStrategy === 'silentAndLater') + ? 'setNeedUpdate' + : 'none', + rnu: cInfo.rnu, + rn: cInfo.rn, + }; + }; + + getNativeConfigJson = (): string | undefined => { + const config = this.getNativeConfig(); + return config ? JSON.stringify(config) : undefined; + }; + + private flushNativeConfig = () => { + if (this.host.options.nativeConfigSource === 'native') { + this.pendingNativeConfigJson = undefined; + this.syncedNativeConfigJson = undefined; + return; + } + const sync = nativeMethod('syncNativeConfig'); + if (!sync || this.nativeConfigSyncInFlight) { + return; + } + const configJson = this.pendingNativeConfigJson; + this.pendingNativeConfigJson = undefined; + if (!configJson || configJson === this.syncedNativeConfigJson) { + return; + } + this.nativeConfigSyncInFlight = true; + sync(configJson) + .then(() => { + // A switch to 'native' while this write was in flight hands storage + // to the host; remembering the write would skip it after switching + // back. + if (this.host.options.nativeConfigSource !== 'native') { + this.syncedNativeConfigJson = configJson; + } + }) + .catch((e: any) => { + log('syncNativeConfig failed:', e?.message || e); + }) + .finally(() => { + this.nativeConfigSyncInFlight = false; + this.flushNativeConfig(); + }); + }; + + /** + * Tell the native cold-start orchestrator that this process already holds a + * valid check response for the current config, so its delayed round (5s + * after launch) does not repeat the request. Fire-and-forget: a failure + * costs one duplicate check. Never called on a failed check — the native + * rescue must keep running when JS could not reach the server. + */ + markJsCheckCompleted = () => { + const markCompleted = nativeMethod('markJsCheckCompleted'); + if (!markCompleted) { + return; + } + const configJson = this.getNativeConfigJson(); + if (!configJson || configJson === this.jsCheckCompletedConfigJson) { + return; + } + markCompleted(configJson).then( + () => { + this.jsCheckCompletedConfigJson = configJson; + }, + (e: any) => { + log('markJsCheckCompleted failed:', e?.message || e); + } + ); + }; + + syncNativeConfig = () => { + if (this.host.options.nativeConfigSource === 'native') { + // The host owns the stored config now; forget what JS last wrote so a + // switch back to 'javascript' writes again instead of deduping + // against a value native storage no longer holds. + this.pendingNativeConfigJson = undefined; + this.syncedNativeConfigJson = undefined; + return; + } + if (!nativeMethod('syncNativeConfig')) { + return; + } + const configJson = this.getNativeConfigJson(); + if (!configJson) { + // No usable config (empty appKey / no endpoints) is almost always a + // transient state while options are being assembled. Persisting a + // disabled config over the last good one would switch off the + // cold-start rescue for every later launch, so keep what native has. + // (Web and older natives never reach this point — no method.) + log('native check config unusable, keeping the last synced config'); + return; + } + // Always record the latest desired value, even when it matches the last + // completed write. Example: A synced -> B in flight -> options revert to + // A. Comparing only with synced(A) would drop the revert and leave native + // storage at B after that in-flight write completes. + // Coalesce rapid setOptions calls, but serialize bridge writes so an older + // completion can never overwrite the newest desired configuration. + this.pendingNativeConfigJson = configJson; + this.flushNativeConfig(); + }; +} diff --git a/src/clientReporter.ts b/src/clientReporter.ts new file mode 100644 index 00000000..38920607 --- /dev/null +++ b/src/clientReporter.ts @@ -0,0 +1,393 @@ +import { + buildTime, + cInfo, + currentVersion, + currentVersionInfo, + packageVersion, +} from './core'; +import { toUpdateError, type UpdateError, type UpdateErrorCode } from './error'; +import { + type ErrorReportContext, + type ErrorReportingOptions, + installGlobalErrorHandler, + type SerializedException, + serializeException, +} from './errorReporting'; +import { + resolveServerEventHash, + resolveServerEventType, + truncateDetail, +} from './telemetry'; +import type { ClientOptions, EventType, UpdateCheckState } from './type'; +import { fetchWithTimeout, log, noop } from './utils'; + +/** One report() event: the logger payload and the source of server telemetry. */ +export interface ReportEvent { + type: EventType; + message?: string; + code?: UpdateErrorCode; + data?: Record; +} + +/** + * Receives every error the client reports, alongside the report event type. + * The UpdateProvider subscribes to surface errors as lastError/Alert; user + * code can subscribe too. Listeners run before any throwError rethrow. + */ +export type UpdateErrorListener = ( + error: UpdateError, + eventType: EventType +) => void; + +/** What reporting needs from the client. */ +export interface ReporterHost { + readonly options: ClientOptions; + getEffectivePackageVersion(): string; + /** The endpoint telemetry posts to: the last one that served a check. */ + telemetryEndpoint(): string | undefined; +} + +/** + * Everything that leaves the client as a signal: the user logger, server + * telemetry, JS exception reports and the onError listeners. Never throws + * into the update flow. + */ +export class Reporter { + constructor(private readonly host: ReporterHost) {} + + private reportedInvalidUpdates = new Set(); + private reportedExceptions = new WeakSet(); + private globalErrorCleanup?: () => void; + private errorListeners = new Set(); + private emittedErrors = new WeakSet(); + + /** Resolved once a logger is configured (see report). */ + loggerPromise = (() => { + let resolve: (value?: unknown) => void = () => {}; + const promise = new Promise((res) => { + resolve = res; + }); + return { + promise, + resolve, + }; + })(); + + syncAutomaticErrorReporting = (): void => { + const { options } = this.host; + if (options.disableTelemetry || options.disableErrorReporting) { + this.globalErrorCleanup?.(); + return; + } + this.enableErrorReporting(); + }; + + reportInvalidUpdateOnce = ( + reason: 'missingHash' | 'noArtifact', + hash = '' + ) => { + const key = `${this.host.options.appKey}:${reason}:${hash}`; + if (this.reportedInvalidUpdates.has(key)) { + return; + } + this.reportedInvalidUpdates.add(key); + this.report({ + type: 'errorUpdate', + message: + reason === 'missingHash' + ? 'update response is missing a version hash' + : 'update response contains no downloadable artifact', + ...(hash ? { data: { newVersion: hash } } : {}), + }); + }; + + /** + * Best-effort JS exception reporting for the currently running OTA release. + * It is intentionally synchronous to call and never throws; the transport + * runs in the background and preserves the app's existing error flow. + */ + captureException = ( + error: unknown, + context: ErrorReportContext = {} + ): void => { + try { + const { options } = this.host; + if ( + __DEV__ || + options.disableTelemetry || + options.disableErrorReporting || + !currentVersion + ) { + return; + } + if ( + error !== null && + (typeof error === 'object' || typeof error === 'function') + ) { + const object = error as object; + if (this.reportedExceptions.has(object)) { + return; + } + this.reportedExceptions.add(object); + } + this.postReport({ + type: 'js_exception', + hash: currentVersion, + error: serializeException(error, context), + }); + } catch (e: any) { + log('exception telemetry error:', e?.message || e); + } + }; + + /** + * Ensure uncaught JS exception capture is installed and return a runtime + * cleanup function. Capture is installed automatically by default; this + * method remains an idempotent compatibility hook. The wrapper always + * chains the handler that was installed before it and only restores that + * handler when no later integration has replaced ours. + */ + enableErrorReporting = ( + options: ErrorReportingOptions = {} + ): (() => void) => { + if ( + options.captureGlobal === false || + this.host.options.disableTelemetry || + this.host.options.disableErrorReporting + ) { + return () => {}; + } + if (this.globalErrorCleanup) { + return this.globalErrorCleanup; + } + const uninstall = installGlobalErrorHandler((error, isFatal) => { + this.captureException(error, { fatal: isFatal === true }); + }); + let active = true; + const cleanup = () => { + if (!active) { + return; + } + active = false; + uninstall(); + if (this.globalErrorCleanup === cleanup) { + this.globalErrorCleanup = undefined; + } + }; + this.globalErrorCleanup = cleanup; + return cleanup; + }; + + report = async ({ type, message = '', code, data = {} }: ReportEvent) => { + log(`${type} ${code ? `[${code}] ` : ''}${message}`); + // Fire-and-forget server telemetry; must not wait for the logger below. + this.reportToServer({ type, message, code, data }); + if (this.host.options.logger === noop) { + // Wait briefly for a logger to arrive via setOptions (e.g. the rollback + // report fires in the constructor before the user configures one), but + // give up after a bound instead of retaining the closure forever when + // no logger is ever provided. + let timer: ReturnType | undefined; + await Promise.race([ + this.loggerPromise.promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(), 10 * 1000); + }), + ]); + clearTimeout(timer); + } + const { logger = noop, appKey } = this.host.options; + const overridePackageVersion = this.host.options.overridePackageVersion; + try { + logger({ + type, + data: { + appKey, + currentVersion, + cInfo, + packageVersion, + overridePackageVersion, + buildTime, + message, + code, + ...currentVersionInfo, + ...data, + }, + }); + } catch (e: any) { + // A user-provided logger must never break the update flow, and report() + // calls are fire-and-forget so a throw here would be an unhandled + // rejection. + log('logger error:', e?.message || e); + } + }; + + /** + * Best-effort lifecycle event reporting to the update server (aggregate + * counts + sampled failure details power the version health view and the + * rollback safety net server-side). Single POST to the last known working + * endpoint, no retry, no fallback race; any failure is swallowed — telemetry + * must never affect the update flow. Opt out with disableTelemetry. + */ + private reportToServer = ({ + type, + message, + code, + data = {}, + }: ReportEvent) => { + try { + if (__DEV__ || this.host.options.disableTelemetry) { + return; + } + const serverType = resolveServerEventType(type, code); + if (!serverType) { + return; + } + const hash = resolveServerEventHash({ serverType, data, currentVersion }); + if (!hash) { + return; + } + // A download rescued by a later strategy reports its patch-health + // signal through downloadFallback, which only counts patch-apply + // failures; downloadSuccess's data.error (any earlier failure, network + // errors included) stays local. + this.postReport({ + type: serverType, + hash, + detail: truncateDetail(message), + }); + } catch (e: any) { + log('telemetry error:', e?.message || e); + } + }; + + /** + * The single telemetry POST ({endpoint}/report/{appKey}) to the last known + * working endpoint; no retry, failures only logged. + */ + private postReport = ({ + type, + hash, + ...payload + }: { + type: string; + hash: string; + detail?: string; + error?: SerializedException; + }) => { + const { appKey } = this.host.options; + const endpoint = this.host.telemetryEndpoint(); + if (!appKey || !endpoint) { + return; + } + fetchWithTimeout(`${endpoint}/report/${appKey}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type, + hash, + packageVersion: this.host.getEffectivePackageVersion(), + cInfo, + ...payload, + }), + }).catch((e: any) => { + log('telemetry report failed:', e?.message || e); + }); + }; + + throwIfEnabled = (e: Error) => { + if (this.host.options.throwError) { + throw e; + } + }; + + /** + * Subscribe to every error the client reports (regardless of throwError). + * Returns an unsubscribe function. + */ + onError = (listener: UpdateErrorListener) => { + this.errorListeners.add(listener); + return () => { + this.errorListeners.delete(listener); + }; + }; + + /** + * Whether this exact error object already went through emitError (and was + * therefore delivered to onError subscribers). Lets UI layers decide if a + * caught error still needs surfacing — checking `e.code` is not enough, + * since axios/system errors carry their own code without ever entering the + * pipeline. + */ + wasEmitted = (e: unknown): boolean => + e instanceof Error && this.emittedErrors.has(e); + + /** + * Single exit point for errors: reports to the logger (with the stable + * code) and notifies onError listeners. Whether to also throw stays with + * the caller (throwIfEnabled or an unconditional rethrow). + */ + emitError = ( + error: UpdateError, + type: EventType, + { + message = error.message, + data, + }: { message?: string; data?: Record } = {} + ) => { + this.emittedErrors.add(error); + this.report({ + type, + message, + code: error.code, + // Structured context from the error (e.g. HTTP status) reaches the + // logger; explicit data wins on key conflicts. + data: error.extra ? { ...error.extra, ...data } : data, + }); + for (const listener of this.errorListeners) { + try { + listener(error, type); + } catch (e: any) { + log('onError listener error:', e?.message || e); + } + } + }; + + /** + * toUpdateError + emitError: how a caught failure leaves the client. + * Returns the error for the caller to throw or pass to throwIfEnabled. + * `fallbackMessage` is reported when the error has no message of its own. + */ + fail = ( + e: unknown, + code: UpdateErrorCode, + type: EventType, + { + message, + fallbackMessage, + data, + }: { + message?: string; + fallbackMessage?: string; + data?: Record; + } = {} + ): UpdateError => { + const err = toUpdateError(e, code); + this.emitError(err, type, { + message: message ?? (err.message || fallbackMessage), + data, + }); + return err; + }; + + notifyAfterCheckUpdate = (state: UpdateCheckState) => { + const { afterCheckUpdate } = this.host.options; + if (!afterCheckUpdate) { + return; + } + // 这里仅做状态通知,不阻塞原有检查流程 + Promise.resolve(afterCheckUpdate(state)).catch((error: any) => { + log('afterCheckUpdate failed:', error?.message || error); + }); + }; +} diff --git a/src/clientState.ts b/src/clientState.ts new file mode 100644 index 00000000..b51ac91c --- /dev/null +++ b/src/clientState.ts @@ -0,0 +1,51 @@ +import type { ProgressData } from './type'; + +export interface SharedState { + downloadingTasks: Record>; + // Progress callbacks per hash: concurrent downloadUpdate callers of the + // same hash each register theirs here instead of the second one being + // silently dropped by the in-flight dedup. + progressCallbacks: Record void>>; + downloadedHash?: string; + toHash?: string; + apkStatus: 'downloading' | 'downloaded' | null; + marked: boolean; + applyingUpdate: boolean; +} + +/** + * Process-wide state of the update flow. The SDK is a process-level + * singleton (one client, see Pushy's constructor); client.ts creates this + * once, when it is evaluated, and hands it to each part of the client. + */ +export interface ProcessState { + shared: SharedState; + // The native cold-start check writes its response cache at most once per + // launch (and skips it once JS has checked), so once this process has seen + // it absent or expired, later reads are near-certain misses: skip the + // bridge call from then on. The rare miss costs one network check. A fresh + // entry that only mismatches the request or config does not set this: a + // later check with the original options (e.g. after `extra` reverts) can + // still reuse it. + nativeCheckCacheExhausted: boolean; + // In-flight native markSuccess shared by concurrent callers, so the native + // call and its telemetry happen once. Cleared once settled. + markingPromise?: Promise; + // A reset/new switch invalidates ownership of older async completions and + // watchdogs before the process-wide applyingUpdate flag can be reused. + applyingUpdateGeneration: number; + reloadWatchdogTimer?: ReturnType; +} + +export const createProcessState = (): ProcessState => ({ + shared: { + downloadingTasks: {}, + progressCallbacks: {}, + downloadedHash: undefined, + apkStatus: null, + marked: false, + applyingUpdate: false, + }, + nativeCheckCacheExhausted: false, + applyingUpdateGeneration: 0, +});