| author | |
| committer | |
| log | 21a3669cee0c6fb54f5afe065cf0eb0348bd232c |
| tree | 441a6f11119ba383ba533ea1477ce8f66f09234d |
| parent | b3b4b57794476c2e130d38f9243f3c5da1a0cbe7 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>13 files changed, 959 insertions(+), 34 deletions(-)
apps/mobile/src/features/home/HomeRouteScreen.tsx+2-1| ... | ... | @@ -11,7 +11,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re |
| 11 | 11 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 12 | 12 | import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; |
| 13 | 13 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| 14 | import { checkForAppUpdateOnLaunch } from "../updates/app-updates"; | |
| 14 | import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; | |
| 15 | 15 | import { AndroidHomeFabLayout } from "./AndroidHomeFab"; |
| 16 | 16 | import { HomeScreen } from "./HomeScreen"; |
| 17 | 17 | import { HomeHeader } from "./HomeHeader"; |
| ... | ... | @@ -34,6 +34,7 @@ export function HomeRouteScreen() { |
| 34 | 34 | |
| 35 | 35 | useEffect(() => { |
| 36 | 36 | void checkForAppUpdateOnLaunch(); |
| 37 | startAppUpdateForegroundRecheck(); | |
| 37 | 38 | }, []); |
| 38 | 39 | |
| 39 | 40 | const { |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+12-5| ... | ... | @@ -608,7 +608,10 @@ function AppSettingsSection() { |
| 608 | 608 | if (updateInFlight.current) return; |
| 609 | 609 | updateInFlight.current = true; |
| 610 | 610 | try { |
| 611 | // The user asked for this restart by tapping the version row, so it may | |
| 612 | // apply immediately instead of prompting. | |
| 611 | 613 | await runAppUpdateCheck({ |
| 614 | applyMode: "immediate", | |
| 612 | 615 | onFailure: (message) => Alert.alert("Update failed", message), |
| 613 | 616 | onStateChange: setUpdateState, |
| 614 | 617 | }); |
| ... | ... | @@ -631,11 +634,15 @@ function AppSettingsSection() { |
| 631 | 634 | ? "Checking…" |
| 632 | 635 | : updateState === "downloading" |
| 633 | 636 | ? "Downloading…" |
| 634 | : updateState === "restarting" | |
| 635 | ? "Restarting…" | |
| 636 | : updateState === "current" | |
| 637 | ? "Up to date" | |
| 638 | : null; | |
| 637 | : // "ready" appears only when this check joined an in-flight background-mode | |
| 638 | // check; that download installs at the next backgrounding. | |
| 639 | updateState === "ready" | |
| 640 | ? "Update ready" | |
| 641 | : updateState === "restarting" | |
| 642 | ? "Restarting…" | |
| 643 | : updateState === "current" | |
| 644 | ? "Up to date" | |
| 645 | : null; | |
| 639 | 646 | |
| 640 | 647 | const versionRow = ( |
| 641 | 648 | <View className="flex-row items-center gap-4 p-4"> |
apps/mobile/src/features/updates/app-updates.test.ts+371-9| ... | ... | @@ -1,11 +1,15 @@ |
| 1 | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | createAppUpdateDeferral, | |
| 4 | 5 | createAppUpdateLaunchCheck, |
| 6 | FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, | |
| 5 | 7 | registerHiddenUpdateTap, |
| 6 | 8 | runAppUpdateCheck, |
| 9 | shouldRecheckAppUpdateOnForeground, | |
| 7 | 10 | type AppUpdateCheckState, |
| 8 | 11 | type AppUpdateClient, |
| 12 | type AppUpdateEnvironment, | |
| 9 | 13 | } from "./app-updates"; |
| 10 | 14 | |
| 11 | 15 | vi.mock("expo-updates", () => ({ |
| ... | ... | @@ -31,6 +35,41 @@ function makeUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateCl |
| 31 | 35 | }; |
| 32 | 36 | } |
| 33 | 37 | |
| 38 | function makeUpdateEnvironment(overrides: Partial<AppUpdateEnvironment> = {}): { | |
| 39 | readonly backgroundCallbacks: Array<() => void>; | |
| 40 | readonly environment: AppUpdateEnvironment; | |
| 41 | readonly foregroundStayCallbacks: Array<() => void>; | |
| 42 | } { | |
| 43 | const backgroundCallbacks: Array<() => void> = []; | |
| 44 | const foregroundStayCallbacks: Array<() => void> = []; | |
| 45 | return { | |
| 46 | backgroundCallbacks, | |
| 47 | foregroundStayCallbacks, | |
| 48 | environment: { | |
| 49 | confirmInstallNow: vi.fn(async () => true), | |
| 50 | flushPendingWrites: vi.fn(async () => {}), | |
| 51 | isSafeToRestartInBackground: vi.fn(async () => true), | |
| 52 | onNextBackground: vi.fn((apply: () => void, _includeCurrent: boolean) => { | |
| 53 | backgroundCallbacks.push(apply); | |
| 54 | }), | |
| 55 | onForegroundStay: vi.fn((apply: () => void) => { | |
| 56 | foregroundStayCallbacks.push(apply); | |
| 57 | }), | |
| 58 | ...overrides, | |
| 59 | }, | |
| 60 | }; | |
| 61 | } | |
| 62 | ||
| 63 | function makeAvailableUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateClient { | |
| 64 | return makeUpdateClient({ | |
| 65 | checkForUpdateAsync: vi.fn(async () => ({ | |
| 66 | isAvailable: true, | |
| 67 | isRollBackToEmbedded: false, | |
| 68 | })), | |
| 69 | ...overrides, | |
| 70 | }); | |
| 71 | } | |
| 72 | ||
| 34 | 73 | describe("runAppUpdateCheck", () => { |
| 35 | 74 | it("does nothing while running from the Metro development server", async () => { |
| 36 | 75 | vi.stubGlobal("__DEV__", true); |
| ... | ... | @@ -45,23 +84,313 @@ describe("runAppUpdateCheck", () => { |
| 45 | 84 | expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); |
| 46 | 85 | }); |
| 47 | 86 | |
| 48 | it("downloads and restarts when a new update is available", async () => { | |
| 49 | const client = makeUpdateClient({ | |
| 50 | checkForUpdateAsync: vi.fn(async () => ({ | |
| 51 | isAvailable: true, | |
| 52 | isRollBackToEmbedded: false, | |
| 53 | })), | |
| 54 | }); | |
| 87 | it("downloads silently and installs at the next backgrounding", async () => { | |
| 88 | const client = makeAvailableUpdateClient(); | |
| 89 | const { backgroundCallbacks, environment } = makeUpdateEnvironment(); | |
| 90 | const deferral = createAppUpdateDeferral(); | |
| 55 | 91 | const states: AppUpdateCheckState[] = []; |
| 56 | 92 | |
| 57 | await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) }); | |
| 93 | await runAppUpdateCheck({ | |
| 94 | client, | |
| 95 | deferral, | |
| 96 | environment, | |
| 97 | onStateChange: (state) => states.push(state), | |
| 98 | }); | |
| 58 | 99 | |
| 59 | 100 | expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); |
| 60 | 101 | expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); |
| 102 | expect(environment.confirmInstallNow).not.toHaveBeenCalled(); | |
| 103 | expect(client.reloadAsync).not.toHaveBeenCalled(); | |
| 104 | expect(states).toEqual(["checking", "downloading", "ready"]); | |
| 105 | expect(deferral.pendingInstall).toBe(true); | |
| 106 | expect(backgroundCallbacks).toHaveLength(1); | |
| 107 | ||
| 108 | backgroundCallbacks[0]!(); | |
| 109 | await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); | |
| 110 | expect(environment.flushPendingWrites).toHaveBeenCalled(); | |
| 111 | }); | |
| 112 | ||
| 113 | it("flushes pending writes before restarting", async () => { | |
| 114 | const client = makeAvailableUpdateClient(); | |
| 115 | const { environment } = makeUpdateEnvironment(); | |
| 116 | ||
| 117 | await runAppUpdateCheck({ | |
| 118 | applyMode: "immediate", | |
| 119 | client, | |
| 120 | deferral: createAppUpdateDeferral(), | |
| 121 | environment, | |
| 122 | }); | |
| 123 | ||
| 124 | const flushOrder = vi.mocked(environment.flushPendingWrites).mock.invocationCallOrder[0]!; | |
| 125 | const reloadOrder = vi.mocked(client.reloadAsync).mock.invocationCallOrder[0]!; | |
| 126 | expect(flushOrder).toBeLessThan(reloadOrder); | |
| 127 | }); | |
| 128 | ||
| 129 | it("prompts once the app has stayed foregrounded with the download waiting", async () => { | |
| 130 | const client = makeAvailableUpdateClient(); | |
| 131 | const { environment, foregroundStayCallbacks } = makeUpdateEnvironment(); | |
| 132 | const deferral = createAppUpdateDeferral(); | |
| 133 | ||
| 134 | await runAppUpdateCheck({ client, deferral, environment }); | |
| 135 | expect(environment.confirmInstallNow).not.toHaveBeenCalled(); | |
| 136 | expect(foregroundStayCallbacks).toHaveLength(1); | |
| 137 | ||
| 138 | foregroundStayCallbacks[0]!(); | |
| 139 | await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); | |
| 140 | expect(environment.confirmInstallNow).toHaveBeenCalledOnce(); | |
| 141 | expect(environment.flushPendingWrites).toHaveBeenCalled(); | |
| 142 | }); | |
| 143 | ||
| 144 | it("keeps the background install armed when the foreground prompt is declined", async () => { | |
| 145 | const client = makeAvailableUpdateClient(); | |
| 146 | const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({ | |
| 147 | confirmInstallNow: vi.fn(async () => false), | |
| 148 | }); | |
| 149 | const deferral = createAppUpdateDeferral(); | |
| 150 | ||
| 151 | await runAppUpdateCheck({ client, deferral, environment }); | |
| 152 | ||
| 153 | foregroundStayCallbacks[0]!(); | |
| 154 | await vi.waitFor(() => expect(environment.confirmInstallNow).toHaveBeenCalledOnce()); | |
| 155 | expect(client.reloadAsync).not.toHaveBeenCalled(); | |
| 156 | expect(deferral.pendingInstall).toBe(true); | |
| 157 | ||
| 158 | backgroundCallbacks[0]!(); | |
| 159 | await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); | |
| 160 | }); | |
| 161 | ||
| 162 | it("skips the foreground prompt once the install is no longer pending", async () => { | |
| 163 | const client = makeAvailableUpdateClient(); | |
| 164 | const { environment, foregroundStayCallbacks } = makeUpdateEnvironment(); | |
| 165 | const deferral = createAppUpdateDeferral(); | |
| 166 | ||
| 167 | await runAppUpdateCheck({ client, deferral, environment }); | |
| 168 | ||
| 169 | // A failed deferred reload resets the deferral before the stay fires. | |
| 170 | deferral.pendingInstall = false; | |
| 171 | foregroundStayCallbacks[0]!(); | |
| 172 | ||
| 173 | expect(environment.confirmInstallNow).not.toHaveBeenCalled(); | |
| 174 | expect(client.reloadAsync).not.toHaveBeenCalled(); | |
| 175 | }); | |
| 176 | ||
| 177 | it("re-arms instead of restarting when the app is no longer safely backgrounded", async () => { | |
| 178 | const client = makeAvailableUpdateClient(); | |
| 179 | const safe = vi.fn(async () => false); | |
| 180 | const { backgroundCallbacks, environment } = makeUpdateEnvironment({ | |
| 181 | isSafeToRestartInBackground: safe, | |
| 182 | }); | |
| 183 | const deferral = createAppUpdateDeferral(); | |
| 184 | ||
| 185 | await runAppUpdateCheck({ client, deferral, environment }); | |
| 186 | expect(backgroundCallbacks).toHaveLength(1); | |
| 187 | // Arming may fire for an already-backgrounded app… | |
| 188 | expect(vi.mocked(environment.onNextBackground).mock.calls[0]![1]).toBe(true); | |
| 189 | ||
| 190 | backgroundCallbacks[0]!(); | |
| 191 | await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2)); | |
| 192 | expect(client.reloadAsync).not.toHaveBeenCalled(); | |
| 193 | expect(deferral.pendingInstall).toBe(true); | |
| 194 | // …but a re-arm must wait for a fresh transition, or an unsafe attempt | |
| 195 | // would retry in a tight loop within the same background session. | |
| 196 | expect(vi.mocked(environment.onNextBackground).mock.calls[1]![1]).toBe(false); | |
| 197 | ||
| 198 | safe.mockResolvedValue(true); | |
| 199 | backgroundCallbacks[1]!(); | |
| 200 | await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); | |
| 201 | }); | |
| 202 | ||
| 203 | it("resets the deferral when the deferred restart fails", async () => { | |
| 204 | const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); | |
| 205 | const client = makeAvailableUpdateClient({ | |
| 206 | reloadAsync: vi.fn(async () => { | |
| 207 | throw new Error("reload rejected"); | |
| 208 | }), | |
| 209 | }); | |
| 210 | const { backgroundCallbacks, environment } = makeUpdateEnvironment(); | |
| 211 | const deferral = createAppUpdateDeferral(); | |
| 212 | ||
| 213 | await runAppUpdateCheck({ client, deferral, environment }); | |
| 214 | backgroundCallbacks[0]!(); | |
| 215 | ||
| 216 | await vi.waitFor(() => expect(deferral.pendingInstall).toBe(false)); | |
| 217 | reportError.mockRestore(); | |
| 218 | }); | |
| 219 | ||
| 220 | it("arms the deferred install once across repeated checks", async () => { | |
| 221 | const client = makeAvailableUpdateClient(); | |
| 222 | const { environment } = makeUpdateEnvironment(); | |
| 223 | const deferral = createAppUpdateDeferral(); | |
| 224 | ||
| 225 | await runAppUpdateCheck({ client, deferral, environment }); | |
| 226 | await runAppUpdateCheck({ client, deferral, environment }); | |
| 227 | ||
| 228 | expect(environment.onNextBackground).toHaveBeenCalledOnce(); | |
| 229 | expect(environment.onForegroundStay).toHaveBeenCalledOnce(); | |
| 230 | }); | |
| 231 | ||
| 232 | it("restarts into an already-downloaded update when the user asks to install", async () => { | |
| 233 | const client = makeUpdateClient(); | |
| 234 | const { environment } = makeUpdateEnvironment(); | |
| 235 | const deferral = createAppUpdateDeferral(); | |
| 236 | deferral.pendingInstall = true; | |
| 237 | ||
| 238 | await runAppUpdateCheck({ applyMode: "immediate", client, deferral, environment }); | |
| 239 | ||
| 240 | expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); | |
| 241 | expect(client.reloadAsync).toHaveBeenCalledOnce(); | |
| 242 | }); | |
| 243 | ||
| 244 | it("honors an immediate request that joined an in-flight background check", async () => { | |
| 245 | let resolveCheck!: (result: { | |
| 246 | readonly isAvailable: boolean; | |
| 247 | readonly isRollBackToEmbedded: boolean; | |
| 248 | }) => void; | |
| 249 | const checkResult = new Promise<{ | |
| 250 | readonly isAvailable: boolean; | |
| 251 | readonly isRollBackToEmbedded: boolean; | |
| 252 | }>((resolve) => { | |
| 253 | resolveCheck = resolve; | |
| 254 | }); | |
| 255 | const client = makeUpdateClient({ | |
| 256 | checkForUpdateAsync: vi.fn(() => checkResult), | |
| 257 | }); | |
| 258 | const { environment } = makeUpdateEnvironment(); | |
| 259 | const deferral = createAppUpdateDeferral(); | |
| 260 | ||
| 261 | const backgroundCheck = runAppUpdateCheck({ client, deferral, environment }); | |
| 262 | const manualCheck = runAppUpdateCheck({ | |
| 263 | applyMode: "immediate", | |
| 264 | client, | |
| 265 | deferral, | |
| 266 | environment, | |
| 267 | }); | |
| 268 | ||
| 269 | resolveCheck({ isAvailable: true, isRollBackToEmbedded: false }); | |
| 270 | await Promise.all([backgroundCheck, manualCheck]); | |
| 271 | ||
| 272 | // The coalesced background check deferred the download, but the manual | |
| 273 | // caller explicitly asked to install, so the restart happens anyway. | |
| 274 | expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); | |
| 275 | expect(client.reloadAsync).toHaveBeenCalledOnce(); | |
| 276 | }); | |
| 277 | ||
| 278 | it("runs a single restart when the deferred install races the foreground prompt", async () => { | |
| 279 | const client = makeAvailableUpdateClient(); | |
| 280 | let releaseFlush!: () => void; | |
| 281 | const blockedFlush = new Promise<void>((resolve) => { | |
| 282 | releaseFlush = resolve; | |
| 283 | }); | |
| 284 | const flushPendingWrites = vi.fn(async (): Promise<void> => {}); | |
| 285 | const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({ | |
| 286 | flushPendingWrites, | |
| 287 | }); | |
| 288 | const deferral = createAppUpdateDeferral(); | |
| 289 | ||
| 290 | await runAppUpdateCheck({ client, deferral, environment }); | |
| 291 | flushPendingWrites.mockReturnValue(blockedFlush); | |
| 292 | ||
| 293 | // The deferred install starts and blocks on its flush; the foreground | |
| 294 | // prompt firing in that window must not begin a second restart. | |
| 295 | backgroundCallbacks[0]!(); | |
| 296 | await vi.waitFor(() => expect(flushPendingWrites).toHaveBeenCalledOnce()); | |
| 297 | foregroundStayCallbacks[0]!(); | |
| 298 | await new Promise((resolve) => setTimeout(resolve, 0)); | |
| 299 | expect(environment.confirmInstallNow).not.toHaveBeenCalled(); | |
| 300 | ||
| 301 | releaseFlush(); | |
| 302 | await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); | |
| 303 | }); | |
| 304 | ||
| 305 | it("holds the deferred restart and re-arms when the pre-restart flush fails", async () => { | |
| 306 | const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); | |
| 307 | const client = makeAvailableUpdateClient(); | |
| 308 | const { backgroundCallbacks, environment } = makeUpdateEnvironment({ | |
| 309 | flushPendingWrites: vi.fn(async () => { | |
| 310 | throw new Error("disk full"); | |
| 311 | }), | |
| 312 | }); | |
| 313 | const deferral = createAppUpdateDeferral(); | |
| 314 | ||
| 315 | await runAppUpdateCheck({ client, deferral, environment }); | |
| 316 | backgroundCallbacks[0]!(); | |
| 317 | ||
| 318 | await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2)); | |
| 319 | expect(client.reloadAsync).not.toHaveBeenCalled(); | |
| 320 | expect(deferral.pendingInstall).toBe(true); | |
| 321 | reportError.mockRestore(); | |
| 322 | }); | |
| 323 | ||
| 324 | it("restarts without prompting when the caller asked for an immediate install", async () => { | |
| 325 | const client = makeAvailableUpdateClient(); | |
| 326 | const { environment } = makeUpdateEnvironment(); | |
| 327 | const states: AppUpdateCheckState[] = []; | |
| 328 | ||
| 329 | await runAppUpdateCheck({ | |
| 330 | applyMode: "immediate", | |
| 331 | client, | |
| 332 | deferral: createAppUpdateDeferral(), | |
| 333 | environment, | |
| 334 | onStateChange: (state) => states.push(state), | |
| 335 | }); | |
| 336 | ||
| 337 | expect(environment.confirmInstallNow).not.toHaveBeenCalled(); | |
| 61 | 338 | expect(client.reloadAsync).toHaveBeenCalledOnce(); |
| 62 | 339 | expect(states).toEqual(["checking", "downloading", "restarting"]); |
| 63 | 340 | }); |
| 64 | 341 | |
| 342 | it("holds an automatic rollback restart when the flush fails and re-arms it", async () => { | |
| 343 | const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); | |
| 344 | const client = makeUpdateClient({ | |
| 345 | checkForUpdateAsync: vi.fn(async () => ({ | |
| 346 | isAvailable: false, | |
| 347 | isRollBackToEmbedded: true, | |
| 348 | })), | |
| 349 | fetchUpdateAsync: vi.fn(async () => ({ | |
| 350 | isNew: false, | |
| 351 | isRollBackToEmbedded: true, | |
| 352 | })), | |
| 353 | }); | |
| 354 | const flushPendingWrites = vi.fn(async (): Promise<void> => { | |
| 355 | throw new Error("storage unavailable"); | |
| 356 | }); | |
| 357 | const { backgroundCallbacks, environment } = makeUpdateEnvironment({ flushPendingWrites }); | |
| 358 | const deferral = createAppUpdateDeferral(); | |
| 359 | ||
| 360 | await runAppUpdateCheck({ client, deferral, environment }); | |
| 361 | ||
| 362 | // Nobody asked for this restart, so it must not discard the state it | |
| 363 | // failed to land; the rollback waits armed for the next backgrounding. | |
| 364 | expect(client.reloadAsync).not.toHaveBeenCalled(); | |
| 365 | expect(deferral.pendingInstall).toBe(true); | |
| 366 | expect(backgroundCallbacks).toHaveLength(1); | |
| 367 | ||
| 368 | flushPendingWrites.mockResolvedValue(undefined); | |
| 369 | backgroundCallbacks[0]!(); | |
| 370 | await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); | |
| 371 | reportError.mockRestore(); | |
| 372 | }); | |
| 373 | ||
| 374 | it("still restarts a user-requested install when the flush fails", async () => { | |
| 375 | const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); | |
| 376 | const client = makeAvailableUpdateClient(); | |
| 377 | const { environment } = makeUpdateEnvironment({ | |
| 378 | flushPendingWrites: vi.fn(async () => { | |
| 379 | throw new Error("storage unavailable"); | |
| 380 | }), | |
| 381 | }); | |
| 382 | ||
| 383 | await runAppUpdateCheck({ | |
| 384 | applyMode: "immediate", | |
| 385 | client, | |
| 386 | deferral: createAppUpdateDeferral(), | |
| 387 | environment, | |
| 388 | }); | |
| 389 | ||
| 390 | expect(client.reloadAsync).toHaveBeenCalledOnce(); | |
| 391 | reportError.mockRestore(); | |
| 392 | }); | |
| 393 | ||
| 65 | 394 | it("restarts into the embedded bundle for a rollback directive", async () => { |
| 66 | 395 | const client = makeUpdateClient({ |
| 67 | 396 | checkForUpdateAsync: vi.fn(async () => ({ |
| ... | ... | @@ -73,10 +402,13 @@ describe("runAppUpdateCheck", () => { |
| 73 | 402 | isRollBackToEmbedded: true, |
| 74 | 403 | })), |
| 75 | 404 | }); |
| 405 | const { environment } = makeUpdateEnvironment(); | |
| 76 | 406 | |
| 77 | await runAppUpdateCheck({ client }); | |
| 407 | await runAppUpdateCheck({ client, deferral: createAppUpdateDeferral(), environment }); | |
| 78 | 408 | |
| 79 | 409 | expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); |
| 410 | // A rollback pulls a broken bundle, so it never waits on the prompt. | |
| 411 | expect(environment.confirmInstallNow).not.toHaveBeenCalled(); | |
| 80 | 412 | expect(client.reloadAsync).toHaveBeenCalledOnce(); |
| 81 | 413 | }); |
| 82 | 414 | |
| ... | ... | @@ -276,6 +608,36 @@ describe("createAppUpdateLaunchCheck", () => { |
| 276 | 608 | }); |
| 277 | 609 | }); |
| 278 | 610 | |
| 611 | describe("shouldRecheckAppUpdateOnForeground", () => { | |
| 612 | it("requires a meaningful background gap", () => { | |
| 613 | expect(shouldRecheckAppUpdateOnForeground(null, 100_000, false)).toBe(false); | |
| 614 | expect( | |
| 615 | shouldRecheckAppUpdateOnForeground( | |
| 616 | 100_000, | |
| 617 | 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS - 1, | |
| 618 | false, | |
| 619 | ), | |
| 620 | ).toBe(false); | |
| 621 | expect( | |
| 622 | shouldRecheckAppUpdateOnForeground( | |
| 623 | 100_000, | |
| 624 | 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, | |
| 625 | false, | |
| 626 | ), | |
| 627 | ).toBe(true); | |
| 628 | }); | |
| 629 | ||
| 630 | it("stays quiet while a downloaded update waits for its install", () => { | |
| 631 | expect( | |
| 632 | shouldRecheckAppUpdateOnForeground( | |
| 633 | 100_000, | |
| 634 | 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, | |
| 635 | true, | |
| 636 | ), | |
| 637 | ).toBe(false); | |
| 638 | }); | |
| 639 | }); | |
| 640 | ||
| 279 | 641 | describe("registerHiddenUpdateTap", () => { |
| 280 | 642 | it("unlocks the manual check on the fifth tap", () => { |
| 281 | 643 | let count = 0; |
apps/mobile/src/features/updates/app-updates.ts+378-1| ... | ... | @@ -8,7 +8,13 @@ import { |
| 8 | 8 | squashAtomCommandFailure, |
| 9 | 9 | } from "@t3tools/client-runtime/state/runtime"; |
| 10 | 10 | |
| 11 | export type AppUpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; | |
| 11 | export type AppUpdateCheckState = | |
| 12 | | "idle" | |
| 13 | | "checking" | |
| 14 | | "downloading" | |
| 15 | | "ready" | |
| 16 | | "restarting" | |
| 17 | | "current"; | |
| 12 | 18 | |
| 13 | 19 | export interface AppUpdateClient { |
| 14 | 20 | readonly isEnabled: boolean; |
| ... | ... | @@ -23,8 +29,70 @@ export interface AppUpdateClient { |
| 23 | 29 | readonly reloadAsync: () => Promise<void>; |
| 24 | 30 | } |
| 25 | 31 | |
| 32 | /** | |
| 33 | * The pieces of the app the update flow has to coordinate with before it may | |
| 34 | * tear down the JavaScript runtime. Injectable so the flow stays unit-testable. | |
| 35 | */ | |
| 36 | export interface AppUpdateEnvironment { | |
| 37 | /** Asks the user to install the waiting update now; `false` keeps it deferred. */ | |
| 38 | readonly confirmInstallNow: () => Promise<boolean>; | |
| 39 | /** | |
| 40 | * Lands persisted state (drafts, outbox) before the restart. Rejects when a | |
| 41 | * write failed, so a silent restart can hold off instead of dropping the | |
| 42 | * unsaved in-memory state. | |
| 43 | */ | |
| 44 | readonly flushPendingWrites: () => Promise<void>; | |
| 45 | /** | |
| 46 | * Whether a deferred restart may fire right now: the app must still be | |
| 47 | * backgrounded (flush latency or an iOS suspend can push the continuation | |
| 48 | * into the next foreground session) and not merely paused behind an | |
| 49 | * app-initiated handoff like the Android image picker. | |
| 50 | */ | |
| 51 | readonly isSafeToRestartInBackground: () => Promise<boolean>; | |
| 52 | /** | |
| 53 | * Runs `apply` the next time the app enters the background. With | |
| 54 | * `includeCurrent`, an app that is already backgrounded fires immediately | |
| 55 | * (so a backgrounding that raced module load is not missed); without it, | |
| 56 | * only a future transition fires, so an attempt that already failed in the | |
| 57 | * current background session cannot retry in a tight loop. | |
| 58 | */ | |
| 59 | readonly onNextBackground: (apply: () => void, includeCurrent: boolean) => void; | |
| 60 | /** | |
| 61 | * Runs `apply` once the app has stayed foregrounded for the whole prompt | |
| 62 | * window — the signal that a deferred install has had no backgrounding to | |
| 63 | * ride on. | |
| 64 | */ | |
| 65 | readonly onForegroundStay: (apply: () => void) => void; | |
| 66 | } | |
| 67 | ||
| 68 | /** Tracks a downloaded update waiting for a safe moment to install. */ | |
| 69 | export interface AppUpdateDeferral { | |
| 70 | pendingInstall: boolean; | |
| 71 | /** | |
| 72 | * Claimed by whichever restart sequence (deferred backgrounding, foreground | |
| 73 | * prompt, manual install) starts first, so racing paths cannot tear down | |
| 74 | * the runtime twice. | |
| 75 | */ | |
| 76 | installInProgress: boolean; | |
| 77 | } | |
| 78 | ||
| 79 | export function createAppUpdateDeferral(): AppUpdateDeferral { | |
| 80 | return { pendingInstall: false, installInProgress: false }; | |
| 81 | } | |
| 82 | ||
| 83 | const appUpdateDeferral = createAppUpdateDeferral(); | |
| 84 | ||
| 26 | 85 | interface AppUpdateCheckOptions { |
| 86 | /** | |
| 87 | * "background" (default) installs silently at the next backgrounding, | |
| 88 | * asking only if the app then stays foregrounded so long that the install | |
| 89 | * never gets its chance. "immediate" restarts as soon as the download | |
| 90 | * lands — reserved for flows where the user explicitly requested the update. | |
| 91 | */ | |
| 92 | readonly applyMode?: "background" | "immediate"; | |
| 27 | 93 | readonly client?: AppUpdateClient; |
| 94 | readonly deferral?: AppUpdateDeferral; | |
| 95 | readonly environment?: AppUpdateEnvironment; | |
| 28 | 96 | readonly onFailure?: (message: string) => void; |
| 29 | 97 | readonly onStateChange?: (state: AppUpdateCheckState) => void; |
| 30 | 98 | } |
| ... | ... | @@ -86,6 +154,15 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr |
| 86 | 154 | |
| 87 | 155 | if (appUpdateCheckInFlight) { |
| 88 | 156 | await observeAppUpdateCheck(appUpdateCheckInFlight, options); |
| 157 | // A background-mode check in flight may have deferred the download this | |
| 158 | // caller explicitly asked to install; honor the explicit request now. | |
| 159 | if (options.applyMode === "immediate") { | |
| 160 | const deferral = options.deferral ?? appUpdateDeferral; | |
| 161 | if (deferral.pendingInstall) { | |
| 162 | const environment = options.environment ?? defaultAppUpdateEnvironment; | |
| 163 | await installPendingAppUpdate(client, environment, deferral, options); | |
| 164 | } | |
| 165 | } | |
| 89 | 166 | return; |
| 90 | 167 | } |
| 91 | 168 | |
| ... | ... | @@ -109,6 +186,9 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr |
| 109 | 186 | appUpdateCheckInFlight = inFlight; |
| 110 | 187 | |
| 111 | 188 | const execution = performAppUpdateCheck(client, { |
| 189 | applyMode: options.applyMode, | |
| 190 | deferral: options.deferral, | |
| 191 | environment: options.environment, | |
| 112 | 192 | onFailure: (message) => { |
| 113 | 193 | progress.failure = message; |
| 114 | 194 | notifyListeners(failureListeners, message); |
| ... | ... | @@ -175,6 +255,15 @@ async function performAppUpdateCheck( |
| 175 | 255 | options: AppUpdateCheckOptions, |
| 176 | 256 | ): Promise<void> { |
| 177 | 257 | const setState = options.onStateChange ?? (() => {}); |
| 258 | const environment = options.environment ?? defaultAppUpdateEnvironment; | |
| 259 | const deferral = options.deferral ?? appUpdateDeferral; | |
| 260 | ||
| 261 | // The user explicitly asked to install and a previous check has already | |
| 262 | // downloaded the update; restart into it without another network round trip. | |
| 263 | if (options.applyMode === "immediate" && deferral.pendingInstall) { | |
| 264 | await installPendingAppUpdate(client, environment, deferral, options); | |
| 265 | return; | |
| 266 | } | |
| 178 | 267 | |
| 179 | 268 | setState("checking"); |
| 180 | 269 | const check = await settlePromise(() => client.checkForUpdateAsync()); |
| ... | ... | @@ -203,14 +292,252 @@ async function performAppUpdateCheck( |
| 203 | 292 | return; |
| 204 | 293 | } |
| 205 | 294 | |
| 295 | // A rollback directive exists to pull a broken bundle; never hold it | |
| 296 | // behind a prompt or a deferred install. | |
| 297 | if (options.applyMode === "immediate" || fetched.value.isRollBackToEmbedded) { | |
| 298 | const outcome = await installAppUpdate( | |
| 299 | client, | |
| 300 | environment, | |
| 301 | deferral, | |
| 302 | options, | |
| 303 | options.applyMode === "immediate", | |
| 304 | ); | |
| 305 | if (outcome === "flush-failed") { | |
| 306 | // Only reachable for an automatic rollback: keep the state-bearing | |
| 307 | // runtime alive and retry like a deferred install. The fetched rollback | |
| 308 | // still applies at the next cold start regardless. | |
| 309 | setState("ready"); | |
| 310 | armDeferredAppUpdateInstall(client, environment, deferral); | |
| 311 | } | |
| 312 | return; | |
| 313 | } | |
| 314 | ||
| 315 | setState("ready"); | |
| 316 | armDeferredAppUpdateInstall(client, environment, deferral); | |
| 317 | } | |
| 318 | ||
| 319 | type AppUpdateInstallOutcome = "installed" | "flush-failed" | "restart-failed"; | |
| 320 | ||
| 321 | /** | |
| 322 | * Restarting mid-session while native surfaces are mounted is the crashiest | |
| 323 | * moment expo-updates has, so the restart flushes persistence first and, by | |
| 324 | * default, waits for a backgrounding — where nothing is rendering and the | |
| 325 | * teardown is invisible. Only a restart the user explicitly asked for may | |
| 326 | * proceed over a failed flush; an automatic one aborts with "flush-failed" | |
| 327 | * so unsaved state is never silently discarded. | |
| 328 | */ | |
| 329 | async function installAppUpdate( | |
| 330 | client: AppUpdateClient, | |
| 331 | environment: AppUpdateEnvironment, | |
| 332 | deferral: AppUpdateDeferral, | |
| 333 | options: AppUpdateCheckOptions, | |
| 334 | userRequested: boolean, | |
| 335 | ): Promise<AppUpdateInstallOutcome> { | |
| 336 | // A concurrent install sequence already owns the restart. | |
| 337 | if (deferral.installInProgress) return "installed"; | |
| 338 | deferral.installInProgress = true; | |
| 339 | const setState = options.onStateChange ?? (() => {}); | |
| 206 | 340 | setState("restarting"); |
| 341 | const flushed = await settlePromise(() => environment.flushPendingWrites()); | |
| 342 | if (flushed._tag === "Failure") { | |
| 343 | reportUpdateFailure(flushed, "Could not save pending state.", undefined); | |
| 344 | if (!userRequested) { | |
| 345 | deferral.installInProgress = false; | |
| 346 | return "flush-failed"; | |
| 347 | } | |
| 348 | } | |
| 207 | 349 | const reloaded = await settlePromise(() => client.reloadAsync()); |
| 208 | 350 | if (reloaded._tag === "Failure") { |
| 209 | 351 | reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", options.onFailure); |
| 210 | 352 | setState("idle"); |
| 353 | deferral.installInProgress = false; | |
| 354 | return "restart-failed"; | |
| 355 | } | |
| 356 | return "installed"; | |
| 357 | } | |
| 358 | ||
| 359 | /** Restarts into an already-downloaded update at the user's request. */ | |
| 360 | async function installPendingAppUpdate( | |
| 361 | client: AppUpdateClient, | |
| 362 | environment: AppUpdateEnvironment, | |
| 363 | deferral: AppUpdateDeferral, | |
| 364 | options: AppUpdateCheckOptions, | |
| 365 | ): Promise<void> { | |
| 366 | const outcome = await installAppUpdate(client, environment, deferral, options, true); | |
| 367 | if (outcome === "restart-failed") { | |
| 368 | // Let later checks re-arm the install; the downloaded update still | |
| 369 | // applies at the next cold start regardless. | |
| 370 | deferral.pendingInstall = false; | |
| 371 | } | |
| 372 | } | |
| 373 | ||
| 374 | function armDeferredAppUpdateInstall( | |
| 375 | client: AppUpdateClient, | |
| 376 | environment: AppUpdateEnvironment, | |
| 377 | deferral: AppUpdateDeferral, | |
| 378 | ): void { | |
| 379 | if (deferral.pendingInstall) return; | |
| 380 | deferral.pendingInstall = true; | |
| 381 | scheduleDeferredAppUpdateInstall(client, environment, deferral, true); | |
| 382 | environment.onForegroundStay(() => { | |
| 383 | void promptDeferredAppUpdateInstall(client, environment, deferral); | |
| 384 | }); | |
| 385 | } | |
| 386 | ||
| 387 | /** | |
| 388 | * A deferred install normally rides the next backgrounding, but a session that | |
| 389 | * never leaves the foreground would sit on the download forever. Only then is | |
| 390 | * the user asked, and declining simply leaves the background install armed. | |
| 391 | */ | |
| 392 | async function promptDeferredAppUpdateInstall( | |
| 393 | client: AppUpdateClient, | |
| 394 | environment: AppUpdateEnvironment, | |
| 395 | deferral: AppUpdateDeferral, | |
| 396 | ): Promise<void> { | |
| 397 | if (!deferral.pendingInstall || deferral.installInProgress) return; | |
| 398 | const installNow = await settlePromise(() => environment.confirmInstallNow()); | |
| 399 | if (installNow._tag !== "Success" || !installNow.value) return; | |
| 400 | // A backgrounding while the alert was up may have started the deferred | |
| 401 | // restart already; the stale accept must not start a second one. | |
| 402 | if (!deferral.pendingInstall || deferral.installInProgress) return; | |
| 403 | await installPendingAppUpdate(client, environment, deferral, {}); | |
| 404 | } | |
| 405 | ||
| 406 | function scheduleDeferredAppUpdateInstall( | |
| 407 | client: AppUpdateClient, | |
| 408 | environment: AppUpdateEnvironment, | |
| 409 | deferral: AppUpdateDeferral, | |
| 410 | includeCurrent: boolean, | |
| 411 | ): void { | |
| 412 | environment.onNextBackground(() => { | |
| 413 | void applyDeferredAppUpdateInstall(client, environment, deferral); | |
| 414 | }, includeCurrent); | |
| 415 | } | |
| 416 | ||
| 417 | async function applyDeferredAppUpdateInstall( | |
| 418 | client: AppUpdateClient, | |
| 419 | environment: AppUpdateEnvironment, | |
| 420 | deferral: AppUpdateDeferral, | |
| 421 | ): Promise<void> { | |
| 422 | if (!deferral.pendingInstall || deferral.installInProgress) return; | |
| 423 | deferral.installInProgress = true; | |
| 424 | const flushed = await settlePromise(() => environment.flushPendingWrites()); | |
| 425 | const safe = await settlePromise(() => environment.isSafeToRestartInBackground()); | |
| 426 | if (flushed._tag === "Failure" || safe._tag !== "Success" || !safe.value) { | |
| 427 | if (flushed._tag === "Failure") { | |
| 428 | // Nothing is lost yet: keep the state-bearing runtime alive and retry | |
| 429 | // the flush at the next backgrounding instead of restarting over it. | |
| 430 | reportUpdateFailure(flushed, "Could not save pending state.", undefined); | |
| 431 | } | |
| 432 | deferral.installInProgress = false; | |
| 433 | // This attempt already ran in the current background session; retrying | |
| 434 | // before a fresh transition would just loop over the same failure. | |
| 435 | scheduleDeferredAppUpdateInstall(client, environment, deferral, false); | |
| 436 | return; | |
| 437 | } | |
| 438 | const reloaded = await settlePromise(() => client.reloadAsync()); | |
| 439 | if (reloaded._tag === "Failure") { | |
| 440 | reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", undefined); | |
| 441 | deferral.installInProgress = false; | |
| 442 | // Let later checks re-arm the install; the downloaded update still | |
| 443 | // applies at the next cold start regardless. | |
| 444 | deferral.pendingInstall = false; | |
| 211 | 445 | } |
| 212 | 446 | } |
| 213 | 447 | |
| 448 | async function defaultConfirmInstallNow(): Promise<boolean> { | |
| 449 | const { Alert } = await import("react-native"); | |
| 450 | return new Promise<boolean>((resolve) => { | |
| 451 | Alert.alert( | |
| 452 | "Update ready", | |
| 453 | "A new version has been downloaded and installs automatically the next time you leave the app. Install it now instead?", | |
| 454 | [ | |
| 455 | { onPress: () => resolve(false), style: "cancel", text: "Later" }, | |
| 456 | { onPress: () => resolve(true), text: "Install Now" }, | |
| 457 | ], | |
| 458 | { cancelable: true, onDismiss: () => resolve(false) }, | |
| 459 | ); | |
| 460 | }); | |
| 461 | } | |
| 462 | ||
| 463 | async function defaultFlushPendingWrites(): Promise<void> { | |
| 464 | // Attempt every flush before surfacing the first failure, so one broken | |
| 465 | // store cannot keep the others from landing. | |
| 466 | const results = await Promise.allSettled([ | |
| 467 | import("../../state/use-composer-drafts").then((drafts) => drafts.flushComposerDrafts()), | |
| 468 | import("../../state/thread-outbox").then((outbox) => outbox.flushThreadOutbox()), | |
| 469 | ]); | |
| 470 | const failed = results.find( | |
| 471 | (result): result is PromiseRejectedResult => result.status === "rejected", | |
| 472 | ); | |
| 473 | if (failed) throw failed.reason; | |
| 474 | } | |
| 475 | ||
| 476 | async function defaultIsSafeToRestartInBackground(): Promise<boolean> { | |
| 477 | const { isForegroundHandoffActive } = await import("../../lib/foreground-handoff"); | |
| 478 | if (isForegroundHandoffActive()) return false; | |
| 479 | const { AppState } = await import("react-native"); | |
| 480 | return AppState.currentState === "background"; | |
| 481 | } | |
| 482 | ||
| 483 | function defaultOnNextBackground(apply: () => void, includeCurrent: boolean): void { | |
| 484 | void import("react-native").then(({ AppState }) => { | |
| 485 | const subscription = AppState.addEventListener("change", (state) => { | |
| 486 | if (state !== "background") return; | |
| 487 | subscription.remove(); | |
| 488 | apply(); | |
| 489 | }); | |
| 490 | // The app may already have backgrounded while this module was loading; | |
| 491 | // the listener alone would then wait a whole extra foreground cycle. | |
| 492 | if (includeCurrent && AppState.currentState === "background") { | |
| 493 | subscription.remove(); | |
| 494 | apply(); | |
| 495 | } | |
| 496 | }); | |
| 497 | } | |
| 498 | ||
| 499 | /** | |
| 500 | * How long the app may stay foregrounded with a downloaded update before the | |
| 501 | * install prompt appears. Long enough that most sessions background naturally | |
| 502 | * and install silently instead. | |
| 503 | */ | |
| 504 | export const DEFERRED_INSTALL_PROMPT_AFTER_MS = 30 * 60 * 1000; | |
| 505 | ||
| 506 | /** | |
| 507 | * The window resets on every backgrounding because that is exactly when the | |
| 508 | * deferred install gets its chance. iOS "inactive" blips (app switcher, a | |
| 509 | * pulled-down notification shade) leave the timer running. | |
| 510 | */ | |
| 511 | function defaultOnForegroundStay(apply: () => void): void { | |
| 512 | void import("react-native").then(({ AppState }) => { | |
| 513 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 514 | const arm = () => { | |
| 515 | timer ??= setTimeout(() => { | |
| 516 | subscription.remove(); | |
| 517 | apply(); | |
| 518 | }, DEFERRED_INSTALL_PROMPT_AFTER_MS); | |
| 519 | }; | |
| 520 | const disarm = () => { | |
| 521 | if (timer === undefined) return; | |
| 522 | clearTimeout(timer); | |
| 523 | timer = undefined; | |
| 524 | }; | |
| 525 | const subscription = AppState.addEventListener("change", (state) => { | |
| 526 | if (state === "active") arm(); | |
| 527 | else if (state === "background") disarm(); | |
| 528 | }); | |
| 529 | if (AppState.currentState === "active") arm(); | |
| 530 | }); | |
| 531 | } | |
| 532 | ||
| 533 | const defaultAppUpdateEnvironment: AppUpdateEnvironment = { | |
| 534 | confirmInstallNow: defaultConfirmInstallNow, | |
| 535 | flushPendingWrites: defaultFlushPendingWrites, | |
| 536 | isSafeToRestartInBackground: defaultIsSafeToRestartInBackground, | |
| 537 | onNextBackground: defaultOnNextBackground, | |
| 538 | onForegroundStay: defaultOnForegroundStay, | |
| 539 | }; | |
| 540 | ||
| 214 | 541 | function reportUpdateFailure( |
| 215 | 542 | result: AtomCommandResult<unknown, unknown>, |
| 216 | 543 | fallback: string, |
| ... | ... | @@ -243,3 +570,53 @@ export function createAppUpdateLaunchCheck( |
| 243 | 570 | } |
| 244 | 571 | |
| 245 | 572 | export const checkForAppUpdateOnLaunch = createAppUpdateLaunchCheck(); |
| 573 | ||
| 574 | /** | |
| 575 | * The app can stay resident for days, so a launch-only check misses updates | |
| 576 | * published while it was in memory. Anything shorter reads as noise: brief | |
| 577 | * app switches should not trigger network checks or an install prompt. | |
| 578 | */ | |
| 579 | export const FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS = 15 * 60 * 1000; | |
| 580 | ||
| 581 | export function shouldRecheckAppUpdateOnForeground( | |
| 582 | backgroundedAtMs: number | null, | |
| 583 | activeAtMs: number, | |
| 584 | pendingInstall: boolean, | |
| 585 | ): boolean { | |
| 586 | if (pendingInstall) return false; | |
| 587 | return ( | |
| 588 | backgroundedAtMs !== null && | |
| 589 | activeAtMs - backgroundedAtMs >= FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS | |
| 590 | ); | |
| 591 | } | |
| 592 | ||
| 593 | export function createAppUpdateForegroundRecheck( | |
| 594 | client: AppUpdateClient = Updates, | |
| 595 | deferral: AppUpdateDeferral = appUpdateDeferral, | |
| 596 | ): () => void { | |
| 597 | let started = false; | |
| 598 | ||
| 599 | return () => { | |
| 600 | if (started || !isAppUpdateCheckAvailable(client)) return; | |
| 601 | started = true; | |
| 602 | void import("react-native").then(({ AppState }) => { | |
| 603 | let backgroundedAtMs: number | null = null; | |
| 604 | AppState.addEventListener("change", (state) => { | |
| 605 | if (state === "background") { | |
| 606 | backgroundedAtMs = Date.now(); | |
| 607 | return; | |
| 608 | } | |
| 609 | if (state !== "active") return; | |
| 610 | const shouldCheck = shouldRecheckAppUpdateOnForeground( | |
| 611 | backgroundedAtMs, | |
| 612 | Date.now(), | |
| 613 | deferral.pendingInstall, | |
| 614 | ); | |
| 615 | backgroundedAtMs = null; | |
| 616 | if (shouldCheck) void runAppUpdateCheck({ client, deferral }); | |
| 617 | }); | |
| 618 | }); | |
| 619 | }; | |
| 620 | } | |
| 621 | ||
| 622 | export const startAppUpdateForegroundRecheck = createAppUpdateForegroundRecheck(); |
apps/mobile/src/lib/atomic-file.ts created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | import type { File } from "expo-file-system"; | |
| 2 | ||
| 3 | let tempFileSequence = 0; | |
| 4 | ||
| 5 | /** | |
| 6 | * Replaces a file's contents through a sibling temp file and an overwriting | |
| 7 | * rename, so an interrupted write (app restart, process death) never leaves a | |
| 8 | * truncated document at the final path. Each write stages through its own | |
| 9 | * temp file so concurrent writers to the same destination cannot move or | |
| 10 | * clobber each other's staging file mid-flight. | |
| 11 | */ | |
| 12 | export async function writeFileAtomically(file: File, contents: string): Promise<void> { | |
| 13 | const { File: FileConstructor } = await import("expo-file-system"); | |
| 14 | tempFileSequence += 1; | |
| 15 | const temp = new FileConstructor(file.parentDirectory, `${file.name}.${tempFileSequence}.tmp`); | |
| 16 | temp.create({ intermediates: true, overwrite: true }); | |
| 17 | temp.write(contents); | |
| 18 | temp.moveSync(file, { overwrite: true }); | |
| 19 | } |
apps/mobile/src/lib/composerImages.ts+16-7| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | type UploadChatImageAttachment, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { estimateBase64ByteSize } from "./base64"; |
| 7 | import { beginForegroundHandoff } from "./foreground-handoff"; | |
| 7 | 8 | import { uuidv4 } from "./uuid"; |
| 8 | 9 | |
| 9 | 10 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { |
| ... | ... | @@ -65,13 +66,21 @@ export async function pickComposerImages(input: { readonly existingCount: number |
| 65 | 66 | }; |
| 66 | 67 | } |
| 67 | 68 | |
| 68 | const result = await imagePicker.launchImageLibraryAsync({ | |
| 69 | mediaTypes: ["images"], | |
| 70 | allowsMultipleSelection: true, | |
| 71 | selectionLimit: remainingSlots, | |
| 72 | base64: true, | |
| 73 | quality: 1, | |
| 74 | }); | |
| 69 | // The picker covers the Android activity, which reports the app as | |
| 70 | // backgrounded; the guard keeps background-triggered restarts away mid-pick. | |
| 71 | const endHandoff = beginForegroundHandoff(); | |
| 72 | let result: Awaited<ReturnType<typeof imagePicker.launchImageLibraryAsync>>; | |
| 73 | try { | |
| 74 | result = await imagePicker.launchImageLibraryAsync({ | |
| 75 | mediaTypes: ["images"], | |
| 76 | allowsMultipleSelection: true, | |
| 77 | selectionLimit: remainingSlots, | |
| 78 | base64: true, | |
| 79 | quality: 1, | |
| 80 | }); | |
| 81 | } finally { | |
| 82 | endHandoff(); | |
| 83 | } | |
| 75 | 84 | |
| 76 | 85 | if (result.canceled) { |
| 77 | 86 | return { |
apps/mobile/src/lib/foreground-handoff.test.ts created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { beginForegroundHandoff, isForegroundHandoffActive } from "./foreground-handoff"; | |
| 4 | ||
| 5 | describe("foreground handoff", () => { | |
| 6 | it("is active only while a handoff is open", () => { | |
| 7 | expect(isForegroundHandoffActive()).toBe(false); | |
| 8 | const end = beginForegroundHandoff(); | |
| 9 | expect(isForegroundHandoffActive()).toBe(true); | |
| 10 | end(); | |
| 11 | expect(isForegroundHandoffActive()).toBe(false); | |
| 12 | }); | |
| 13 | ||
| 14 | it("stays active until every overlapping handoff ends", () => { | |
| 15 | const endFirst = beginForegroundHandoff(); | |
| 16 | const endSecond = beginForegroundHandoff(); | |
| 17 | endFirst(); | |
| 18 | expect(isForegroundHandoffActive()).toBe(true); | |
| 19 | endSecond(); | |
| 20 | expect(isForegroundHandoffActive()).toBe(false); | |
| 21 | }); | |
| 22 | ||
| 23 | it("tolerates an end function called twice", () => { | |
| 24 | const endFirst = beginForegroundHandoff(); | |
| 25 | const endSecond = beginForegroundHandoff(); | |
| 26 | endFirst(); | |
| 27 | endFirst(); | |
| 28 | expect(isForegroundHandoffActive()).toBe(true); | |
| 29 | endSecond(); | |
| 30 | expect(isForegroundHandoffActive()).toBe(false); | |
| 31 | }); | |
| 32 | }); |
apps/mobile/src/lib/foreground-handoff.ts created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | /** | |
| 2 | * Tracks app-initiated OS-surface handoffs (image picker, auth tab, share | |
| 3 | * sheet). Android reports the app as backgrounded while one of these covers | |
| 4 | * the activity, so background-triggered work — like a deferred app update | |
| 5 | * restart — has to wait them out instead of tearing down the mid-flow runtime. | |
| 6 | */ | |
| 7 | let activeHandoffs = 0; | |
| 8 | ||
| 9 | /** Returns an idempotent end function; call it when the handoff resolves. */ | |
| 10 | export function beginForegroundHandoff(): () => void { | |
| 11 | activeHandoffs += 1; | |
| 12 | let ended = false; | |
| 13 | return () => { | |
| 14 | if (ended) return; | |
| 15 | ended = true; | |
| 16 | activeHandoffs -= 1; | |
| 17 | }; | |
| 18 | } | |
| 19 | ||
| 20 | export function isForegroundHandoffActive(): boolean { | |
| 21 | return activeHandoffs > 0; | |
| 22 | } |
apps/mobile/src/state/thread-outbox-storage.ts+25-5| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | |
| 4 | import { writeFileAtomically } from "../lib/atomic-file"; | |
| 4 | 5 | import { |
| 5 | 6 | decodeQueuedThreadMessage, |
| 6 | 7 | encodeQueuedThreadMessage, |
| ... | ... | @@ -9,6 +10,24 @@ import { |
| 9 | 10 | |
| 10 | 11 | const THREAD_OUTBOX_DIRECTORY = "thread-outbox"; |
| 11 | 12 | |
| 13 | const inFlightWrites = new Set<Promise<void>>(); | |
| 14 | ||
| 15 | function trackInFlightWrite(operation: Promise<void>): Promise<void> { | |
| 16 | inFlightWrites.add(operation); | |
| 17 | void operation.catch(() => undefined).finally(() => inFlightWrites.delete(operation)); | |
| 18 | return operation; | |
| 19 | } | |
| 20 | ||
| 21 | /** | |
| 22 | * Awaits queued-message writes so an app update restart cannot tear down the | |
| 23 | * runtime while one is mid-file. | |
| 24 | */ | |
| 25 | export async function flushThreadOutboxWrites(): Promise<void> { | |
| 26 | while (inFlightWrites.size > 0) { | |
| 27 | await Promise.allSettled(inFlightWrites); | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 12 | 31 | export class ThreadOutboxStorageError extends Schema.TaggedErrorClass<ThreadOutboxStorageError>()( |
| 13 | 32 | "ThreadOutboxStorageError", |
| 14 | 33 | { |
| ... | ... | @@ -89,11 +108,12 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = { |
| 89 | 108 | write: async (message) => { |
| 90 | 109 | const fileName = messageFileName(message.messageId); |
| 91 | 110 | try { |
| 92 | const file = await getMessageFile(message.messageId); | |
| 93 | if (!file.exists) { | |
| 94 | file.create({ intermediates: true, overwrite: true }); | |
| 95 | } | |
| 96 | file.write(JSON.stringify(encodeQueuedThreadMessage(message))); | |
| 111 | await trackInFlightWrite( | |
| 112 | (async () => { | |
| 113 | const file = await getMessageFile(message.messageId); | |
| 114 | await writeFileAtomically(file, JSON.stringify(encodeQueuedThreadMessage(message))); | |
| 115 | })(), | |
| 116 | ); | |
| 97 | 117 | } catch (cause) { |
| 98 | 118 | throw new ThreadOutboxStorageError({ |
| 99 | 119 | operation: "write", |
apps/mobile/src/state/thread-outbox.ts+12-1| ... | ... | @@ -3,7 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; |
| 3 | 3 | import { appAtomRegistry } from "./atom-registry"; |
| 4 | 4 | import { createThreadOutboxManager } from "./thread-outbox-manager"; |
| 5 | 5 | import type { QueuedThreadMessage } from "./thread-outbox-model"; |
| 6 | import { expoThreadOutboxStorage } from "./thread-outbox-storage"; | |
| 6 | import { expoThreadOutboxStorage, flushThreadOutboxWrites } from "./thread-outbox-storage"; | |
| 7 | 7 | |
| 8 | 8 | export * from "./thread-outbox-model"; |
| 9 | 9 | |
| ... | ... | @@ -12,6 +12,17 @@ export const threadOutboxManager = createThreadOutboxManager({ |
| 12 | 12 | storage: expoThreadOutboxStorage, |
| 13 | 13 | }); |
| 14 | 14 | |
| 15 | /** | |
| 16 | * Lands queued outbox mutations before the JS runtime is torn down (app update | |
| 17 | * restart). An enqueued message is published to the atom immediately but its | |
| 18 | * durable write waits behind the mutation queue, so draining only the writes | |
| 19 | * already mid-file would miss it. | |
| 20 | */ | |
| 21 | export async function flushThreadOutbox(): Promise<void> { | |
| 22 | await threadOutboxManager.serialize(async () => {}); | |
| 23 | await flushThreadOutboxWrites(); | |
| 24 | } | |
| 25 | ||
| 15 | 26 | export function ensureThreadOutboxLoaded(): void { |
| 16 | 27 | void threadOutboxManager.load(); |
| 17 | 28 | } |
apps/mobile/src/state/use-composer-drafts.test.ts+39| ... | ... | @@ -4,6 +4,7 @@ import { vi } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | 5 | const composerDraftFileMocks = vi.hoisted(() => { |
| 6 | 6 | let document = ""; |
| 7 | let writeError: Error | null = null; | |
| 7 | 8 | let releaseRead: (() => void) | null = null; |
| 8 | 9 | let readBarrier = Promise.resolve(); |
| 9 | 10 | |
| ... | ... | @@ -17,23 +18,35 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 17 | 18 | releaseRead?.(); |
| 18 | 19 | releaseRead = null; |
| 19 | 20 | }, |
| 21 | getDocument() { | |
| 22 | return document; | |
| 23 | }, | |
| 20 | 24 | setDocument(value: unknown) { |
| 21 | 25 | document = JSON.stringify(value); |
| 22 | 26 | }, |
| 27 | setWriteError(error: Error | null) { | |
| 28 | writeError = error; | |
| 29 | }, | |
| 23 | 30 | Directory: class { |
| 24 | 31 | create() {} |
| 25 | 32 | }, |
| 26 | 33 | File: class { |
| 27 | 34 | exists = true; |
| 35 | parentDirectory = null; | |
| 28 | 36 | |
| 29 | 37 | create() {} |
| 30 | 38 | |
| 39 | moveSync() {} | |
| 40 | ||
| 31 | 41 | async text() { |
| 32 | 42 | await readBarrier; |
| 33 | 43 | return document; |
| 34 | 44 | } |
| 35 | 45 | |
| 36 | 46 | write(value: string) { |
| 47 | if (writeError) { | |
| 48 | throw writeError; | |
| 49 | } | |
| 37 | 50 | document = value; |
| 38 | 51 | } |
| 39 | 52 | }, |
| ... | ... | @@ -49,15 +62,18 @@ vi.mock("expo-file-system", () => ({ |
| 49 | 62 | import { appAtomRegistry } from "./atom-registry"; |
| 50 | 63 | import { |
| 51 | 64 | clearComposerDraftContentState, |
| 65 | ComposerDraftPersistenceError, | |
| 52 | 66 | composerDraftsAtom, |
| 53 | 67 | copyComposerDraftContentIfEmpty, |
| 54 | 68 | copyComposerDraftContentState, |
| 55 | 69 | decodePersistedComposerDrafts, |
| 56 | 70 | type ComposerDraft, |
| 71 | flushComposerDrafts, | |
| 57 | 72 | getComposerDraftSnapshot, |
| 58 | 73 | mergeComposerDraftContentState, |
| 59 | 74 | removeComposerDraftsForEnvironment, |
| 60 | 75 | restoreComposerDraftSnapshotState, |
| 76 | setComposerDraftText, | |
| 61 | 77 | } from "./use-composer-drafts"; |
| 62 | 78 | |
| 63 | 79 | const DRAFT: ComposerDraft = { |
| ... | ... | @@ -393,4 +409,27 @@ describe("mobile composer drafts", () => { |
| 393 | 409 | [unrelatedKey]: unrelated, |
| 394 | 410 | }); |
| 395 | 411 | }); |
| 412 | ||
| 413 | it("lands a still-debounced draft write when flushed", async () => { | |
| 414 | const draftKey = "environment-1:thread-1"; | |
| 415 | setComposerDraftText(draftKey, "typed right before the restart"); | |
| 416 | ||
| 417 | await flushComposerDrafts(); | |
| 418 | ||
| 419 | expect(JSON.parse(composerDraftFileMocks.getDocument())).toMatchObject({ | |
| 420 | drafts: { [draftKey]: { text: "typed right before the restart" } }, | |
| 421 | }); | |
| 422 | }); | |
| 423 | ||
| 424 | it("propagates a flush write failure instead of resolving as saved", async () => { | |
| 425 | const draftKey = "environment-1:thread-1"; | |
| 426 | setComposerDraftText(draftKey, "unsaved"); | |
| 427 | composerDraftFileMocks.setWriteError(new Error("storage unavailable")); | |
| 428 | ||
| 429 | try { | |
| 430 | await expect(flushComposerDrafts()).rejects.toBeInstanceOf(ComposerDraftPersistenceError); | |
| 431 | } finally { | |
| 432 | composerDraftFileMocks.setWriteError(null); | |
| 433 | } | |
| 434 | }); | |
| 396 | 435 | }); |
apps/mobile/src/state/use-composer-drafts.ts+23-5| ... | ... | @@ -13,6 +13,7 @@ import * as Schema from "effect/Schema"; |
| 13 | 13 | import { useEffect } from "react"; |
| 14 | 14 | import { Atom } from "effect/unstable/reactivity"; |
| 15 | 15 | |
| 16 | import { writeFileAtomically } from "../lib/atomic-file"; | |
| 16 | 17 | import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; |
| 17 | 18 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 18 | 19 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; |
| ... | ... | @@ -188,10 +189,7 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft |
| 188 | 189 | } as const; |
| 189 | 190 | const encoded = JSON.stringify(document); |
| 190 | 191 | operation = "write"; |
| 191 | if (!file.exists) { | |
| 192 | file.create({ intermediates: true, overwrite: true }); | |
| 193 | } | |
| 194 | file.write(encoded); | |
| 192 | await writeFileAtomically(file, encoded); | |
| 195 | 193 | } catch (cause) { |
| 196 | 194 | throw new ComposerDraftPersistenceError({ |
| 197 | 195 | operation, |
| ... | ... | @@ -211,6 +209,26 @@ async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft> |
| 211 | 209 | } |
| 212 | 210 | } |
| 213 | 211 | |
| 212 | /** | |
| 213 | * Lands any debounced or in-flight draft write before the JS runtime is torn | |
| 214 | * down (app update restart), so the freshest draft state survives it. A write | |
| 215 | * failure propagates so the caller can decide whether the restart may proceed. | |
| 216 | */ | |
| 217 | export async function flushComposerDrafts(): Promise<void> { | |
| 218 | // An edit during an awaited write schedules another debounced write, so | |
| 219 | // keep landing snapshots until no debounce is pending after a queue drain. | |
| 220 | do { | |
| 221 | while (persistTimer !== null) { | |
| 222 | clearTimeout(persistTimer); | |
| 223 | persistTimer = null; | |
| 224 | await persistenceQueue.run(() => | |
| 225 | writePersistedComposerDrafts(appAtomRegistry.get(composerDraftsAtom)), | |
| 226 | ); | |
| 227 | } | |
| 228 | await persistenceQueue.run(() => Promise.resolve()); | |
| 229 | } while (persistTimer !== null); | |
| 230 | } | |
| 231 | ||
| 214 | 232 | function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): void { |
| 215 | 233 | if (persistTimer !== null) { |
| 216 | 234 | clearTimeout(persistTimer); |
| ... | ... | @@ -627,7 +645,7 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI |
| 627 | 645 | persistTimer = null; |
| 628 | 646 | } |
| 629 | 647 | appAtomRegistry.set(composerDraftsAtom, next); |
| 630 | await writePersistedComposerDrafts(next); | |
| 648 | await persistenceQueue.run(() => writePersistedComposerDrafts(next)); | |
| 631 | 649 | } |
| 632 | 650 | |
| 633 | 651 | export function useComposerDraft(draftKey: string | null): ComposerDraft { |
docs/user/updating.md+8| ... | ... | @@ -70,4 +70,12 @@ If a step fails: |
| 70 | 70 | 3. For a command-line server, relaunch it with `npx t3@<client-version>`, replacing |
| 71 | 71 | `<client-version>` with the client version shown in the warning. |
| 72 | 72 | |
| 73 | ## The Mobile App | |
| 74 | ||
| 75 | The mobile app keeps itself current on its own. When it finds a new version, it downloads it in the | |
| 76 | background and installs it automatically the next time you leave the app. Unsent drafts and queued | |
| 77 | messages are saved before the restart. Only if the app stays open long enough that the update never | |
| 78 | gets that chance does it ask whether to install right away; choosing **Later** is safe and keeps the | |
| 79 | automatic install armed. | |
| 80 | ||
| 73 | 81 | For remote connection setup and access troubleshooting, see [Remote Access](./remote-access.md). |