| 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,7 +11,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re |
| 11 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 11 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 12 | import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; | 12 | import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; |
| 13 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | 13 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| 14 | import { checkForAppUpdateOnLaunch } from "../updates/app-updates"; | 14 | import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; |
| 15 | import { AndroidHomeFabLayout } from "./AndroidHomeFab"; | 15 | import { AndroidHomeFabLayout } from "./AndroidHomeFab"; |
| 16 | import { HomeScreen } from "./HomeScreen"; | 16 | import { HomeScreen } from "./HomeScreen"; |
| 17 | import { HomeHeader } from "./HomeHeader"; | 17 | import { HomeHeader } from "./HomeHeader"; |
| ... | @@ -34,6 +34,7 @@ export function HomeRouteScreen() { | ... | @@ -34,6 +34,7 @@ export function HomeRouteScreen() { |
| 34 | 34 | ||
| 35 | useEffect(() => { | 35 | useEffect(() => { |
| 36 | void checkForAppUpdateOnLaunch(); | 36 | void checkForAppUpdateOnLaunch(); |
| 37 | startAppUpdateForegroundRecheck(); | ||
| 37 | }, []); | 38 | }, []); |
| 38 | 39 | ||
| 39 | const { | 40 | const { |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+12-5| ... | @@ -608,7 +608,10 @@ function AppSettingsSection() { | ... | @@ -608,7 +608,10 @@ function AppSettingsSection() { |
| 608 | if (updateInFlight.current) return; | 608 | if (updateInFlight.current) return; |
| 609 | updateInFlight.current = true; | 609 | updateInFlight.current = true; |
| 610 | try { | 610 | try { |
| 611 | // The user asked for this restart by tapping the version row, so it may | ||
| 612 | // apply immediately instead of prompting. | ||
| 611 | await runAppUpdateCheck({ | 613 | await runAppUpdateCheck({ |
| 614 | applyMode: "immediate", | ||
| 612 | onFailure: (message) => Alert.alert("Update failed", message), | 615 | onFailure: (message) => Alert.alert("Update failed", message), |
| 613 | onStateChange: setUpdateState, | 616 | onStateChange: setUpdateState, |
| 614 | }); | 617 | }); |
| ... | @@ -631,11 +634,15 @@ function AppSettingsSection() { | ... | @@ -631,11 +634,15 @@ function AppSettingsSection() { |
| 631 | ? "Checking…" | 634 | ? "Checking…" |
| 632 | : updateState === "downloading" | 635 | : updateState === "downloading" |
| 633 | ? "Downloading…" | 636 | ? "Downloading…" |
| 634 | : updateState === "restarting" | 637 | : // "ready" appears only when this check joined an in-flight background-mode |
| 635 | ? "Restarting…" | 638 | // check; that download installs at the next backgrounding. |
| 636 | : updateState === "current" | 639 | updateState === "ready" |
| 637 | ? "Up to date" | 640 | ? "Update ready" |
| 638 | : null; | 641 | : updateState === "restarting" |
| 642 | ? "Restarting…" | ||
| 643 | : updateState === "current" | ||
| 644 | ? "Up to date" | ||
| 645 | : null; | ||
| 639 | 646 | ||
| 640 | const versionRow = ( | 647 | const versionRow = ( |
| 641 | <View className="flex-row items-center gap-4 p-4"> | 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,11 +1,15 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | createAppUpdateDeferral, | ||
| 4 | createAppUpdateLaunchCheck, | 5 | createAppUpdateLaunchCheck, |
| 6 | FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, | ||
| 5 | registerHiddenUpdateTap, | 7 | registerHiddenUpdateTap, |
| 6 | runAppUpdateCheck, | 8 | runAppUpdateCheck, |
| 9 | shouldRecheckAppUpdateOnForeground, | ||
| 7 | type AppUpdateCheckState, | 10 | type AppUpdateCheckState, |
| 8 | type AppUpdateClient, | 11 | type AppUpdateClient, |
| 12 | type AppUpdateEnvironment, | ||
| 9 | } from "./app-updates"; | 13 | } from "./app-updates"; |
| 10 | 14 | ||
| 11 | vi.mock("expo-updates", () => ({ | 15 | vi.mock("expo-updates", () => ({ |
| ... | @@ -31,6 +35,41 @@ function makeUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateCl | ... | @@ -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 | describe("runAppUpdateCheck", () => { | 73 | describe("runAppUpdateCheck", () => { |
| 35 | it("does nothing while running from the Metro development server", async () => { | 74 | it("does nothing while running from the Metro development server", async () => { |
| 36 | vi.stubGlobal("__DEV__", true); | 75 | vi.stubGlobal("__DEV__", true); |
| ... | @@ -45,23 +84,313 @@ describe("runAppUpdateCheck", () => { | ... | @@ -45,23 +84,313 @@ describe("runAppUpdateCheck", () => { |
| 45 | expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); | 84 | expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); |
| 46 | }); | 85 | }); |
| 47 | 86 | ||
| 48 | it("downloads and restarts when a new update is available", async () => { | 87 | it("downloads silently and installs at the next backgrounding", async () => { |
| 49 | const client = makeUpdateClient({ | 88 | const client = makeAvailableUpdateClient(); |
| 50 | checkForUpdateAsync: vi.fn(async () => ({ | 89 | const { backgroundCallbacks, environment } = makeUpdateEnvironment(); |
| 51 | isAvailable: true, | 90 | const deferral = createAppUpdateDeferral(); |
| 52 | isRollBackToEmbedded: false, | ||
| 53 | })), | ||
| 54 | }); | ||
| 55 | const states: AppUpdateCheckState[] = []; | 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 | expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); | 100 | expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); |
| 60 | expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); | 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 | expect(client.reloadAsync).toHaveBeenCalledOnce(); | 338 | expect(client.reloadAsync).toHaveBeenCalledOnce(); |
| 62 | expect(states).toEqual(["checking", "downloading", "restarting"]); | 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 | it("restarts into the embedded bundle for a rollback directive", async () => { | 394 | it("restarts into the embedded bundle for a rollback directive", async () => { |
| 66 | const client = makeUpdateClient({ | 395 | const client = makeUpdateClient({ |
| 67 | checkForUpdateAsync: vi.fn(async () => ({ | 396 | checkForUpdateAsync: vi.fn(async () => ({ |
| ... | @@ -73,10 +402,13 @@ describe("runAppUpdateCheck", () => { | ... | @@ -73,10 +402,13 @@ describe("runAppUpdateCheck", () => { |
| 73 | isRollBackToEmbedded: true, | 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 | expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); | 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 | expect(client.reloadAsync).toHaveBeenCalledOnce(); | 412 | expect(client.reloadAsync).toHaveBeenCalledOnce(); |
| 81 | }); | 413 | }); |
| 82 | 414 | ||
| ... | @@ -276,6 +608,36 @@ describe("createAppUpdateLaunchCheck", () => { | ... | @@ -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 | describe("registerHiddenUpdateTap", () => { | 641 | describe("registerHiddenUpdateTap", () => { |
| 280 | it("unlocks the manual check on the fifth tap", () => { | 642 | it("unlocks the manual check on the fifth tap", () => { |
| 281 | let count = 0; | 643 | let count = 0; |
apps/mobile/src/features/updates/app-updates.ts+378-1| ... | @@ -8,7 +8,13 @@ import { | ... | @@ -8,7 +8,13 @@ import { |
| 8 | squashAtomCommandFailure, | 8 | squashAtomCommandFailure, |
| 9 | } from "@t3tools/client-runtime/state/runtime"; | 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 | export interface AppUpdateClient { | 19 | export interface AppUpdateClient { |
| 14 | readonly isEnabled: boolean; | 20 | readonly isEnabled: boolean; |
| ... | @@ -23,8 +29,70 @@ export interface AppUpdateClient { | ... | @@ -23,8 +29,70 @@ export interface AppUpdateClient { |
| 23 | readonly reloadAsync: () => Promise<void>; | 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 | interface AppUpdateCheckOptions { | 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 | readonly client?: AppUpdateClient; | 93 | readonly client?: AppUpdateClient; |
| 94 | readonly deferral?: AppUpdateDeferral; | ||
| 95 | readonly environment?: AppUpdateEnvironment; | ||
| 28 | readonly onFailure?: (message: string) => void; | 96 | readonly onFailure?: (message: string) => void; |
| 29 | readonly onStateChange?: (state: AppUpdateCheckState) => void; | 97 | readonly onStateChange?: (state: AppUpdateCheckState) => void; |
| 30 | } | 98 | } |
| ... | @@ -86,6 +154,15 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr | ... | @@ -86,6 +154,15 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr |
| 86 | 154 | ||
| 87 | if (appUpdateCheckInFlight) { | 155 | if (appUpdateCheckInFlight) { |
| 88 | await observeAppUpdateCheck(appUpdateCheckInFlight, options); | 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 | return; | 166 | return; |
| 90 | } | 167 | } |
| 91 | 168 | ||
| ... | @@ -109,6 +186,9 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr | ... | @@ -109,6 +186,9 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr |
| 109 | appUpdateCheckInFlight = inFlight; | 186 | appUpdateCheckInFlight = inFlight; |
| 110 | 187 | ||
| 111 | const execution = performAppUpdateCheck(client, { | 188 | const execution = performAppUpdateCheck(client, { |
| 189 | applyMode: options.applyMode, | ||
| 190 | deferral: options.deferral, | ||
| 191 | environment: options.environment, | ||
| 112 | onFailure: (message) => { | 192 | onFailure: (message) => { |
| 113 | progress.failure = message; | 193 | progress.failure = message; |
| 114 | notifyListeners(failureListeners, message); | 194 | notifyListeners(failureListeners, message); |
| ... | @@ -175,6 +255,15 @@ async function performAppUpdateCheck( | ... | @@ -175,6 +255,15 @@ async function performAppUpdateCheck( |
| 175 | options: AppUpdateCheckOptions, | 255 | options: AppUpdateCheckOptions, |
| 176 | ): Promise<void> { | 256 | ): Promise<void> { |
| 177 | const setState = options.onStateChange ?? (() => {}); | 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 | setState("checking"); | 268 | setState("checking"); |
| 180 | const check = await settlePromise(() => client.checkForUpdateAsync()); | 269 | const check = await settlePromise(() => client.checkForUpdateAsync()); |
| ... | @@ -203,14 +292,252 @@ async function performAppUpdateCheck( | ... | @@ -203,14 +292,252 @@ async function performAppUpdateCheck( |
| 203 | return; | 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 | setState("restarting"); | 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 | const reloaded = await settlePromise(() => client.reloadAsync()); | 349 | const reloaded = await settlePromise(() => client.reloadAsync()); |
| 208 | if (reloaded._tag === "Failure") { | 350 | if (reloaded._tag === "Failure") { |
| 209 | reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", options.onFailure); | 351 | reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", options.onFailure); |
| 210 | setState("idle"); | 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 | function reportUpdateFailure( | 541 | function reportUpdateFailure( |
| 215 | result: AtomCommandResult<unknown, unknown>, | 542 | result: AtomCommandResult<unknown, unknown>, |
| 216 | fallback: string, | 543 | fallback: string, |
| ... | @@ -243,3 +570,53 @@ export function createAppUpdateLaunchCheck( | ... | @@ -243,3 +570,53 @@ export function createAppUpdateLaunchCheck( |
| 243 | } | 570 | } |
| 244 | 571 | ||
| 245 | export const checkForAppUpdateOnLaunch = createAppUpdateLaunchCheck(); | 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,6 +4,7 @@ import { |
| 4 | type UploadChatImageAttachment, | 4 | type UploadChatImageAttachment, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { estimateBase64ByteSize } from "./base64"; | 6 | import { estimateBase64ByteSize } from "./base64"; |
| 7 | import { beginForegroundHandoff } from "./foreground-handoff"; | ||
| 7 | import { uuidv4 } from "./uuid"; | 8 | import { uuidv4 } from "./uuid"; |
| 8 | 9 | ||
| 9 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { | 10 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { |
| ... | @@ -65,13 +66,21 @@ export async function pickComposerImages(input: { readonly existingCount: number | ... | @@ -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 | // The picker covers the Android activity, which reports the app as |
| 69 | mediaTypes: ["images"], | 70 | // backgrounded; the guard keeps background-triggered restarts away mid-pick. |
| 70 | allowsMultipleSelection: true, | 71 | const endHandoff = beginForegroundHandoff(); |
| 71 | selectionLimit: remainingSlots, | 72 | let result: Awaited<ReturnType<typeof imagePicker.launchImageLibraryAsync>>; |
| 72 | base64: true, | 73 | try { |
| 73 | quality: 1, | 74 | result = await imagePicker.launchImageLibraryAsync({ |
| 74 | }); | 75 | mediaTypes: ["images"], |
| 76 | allowsMultipleSelection: true, | ||
| 77 | selectionLimit: remainingSlots, | ||
| 78 | base64: true, | ||
| 79 | quality: 1, | ||
| 80 | }); | ||
| 81 | } finally { | ||
| 82 | endHandoff(); | ||
| 83 | } | ||
| 75 | 84 | ||
| 76 | if (result.canceled) { | 85 | if (result.canceled) { |
| 77 | return { | 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,6 +1,7 @@ |
| 1 | import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; | 1 | import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | ||
| 4 | import { writeFileAtomically } from "../lib/atomic-file"; | ||
| 4 | import { | 5 | import { |
| 5 | decodeQueuedThreadMessage, | 6 | decodeQueuedThreadMessage, |
| 6 | encodeQueuedThreadMessage, | 7 | encodeQueuedThreadMessage, |
| ... | @@ -9,6 +10,24 @@ import { | ... | @@ -9,6 +10,24 @@ import { |
| 9 | 10 | ||
| 10 | const THREAD_OUTBOX_DIRECTORY = "thread-outbox"; | 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 | export class ThreadOutboxStorageError extends Schema.TaggedErrorClass<ThreadOutboxStorageError>()( | 31 | export class ThreadOutboxStorageError extends Schema.TaggedErrorClass<ThreadOutboxStorageError>()( |
| 13 | "ThreadOutboxStorageError", | 32 | "ThreadOutboxStorageError", |
| 14 | { | 33 | { |
| ... | @@ -89,11 +108,12 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = { | ... | @@ -89,11 +108,12 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = { |
| 89 | write: async (message) => { | 108 | write: async (message) => { |
| 90 | const fileName = messageFileName(message.messageId); | 109 | const fileName = messageFileName(message.messageId); |
| 91 | try { | 110 | try { |
| 92 | const file = await getMessageFile(message.messageId); | 111 | await trackInFlightWrite( |
| 93 | if (!file.exists) { | 112 | (async () => { |
| 94 | file.create({ intermediates: true, overwrite: true }); | 113 | const file = await getMessageFile(message.messageId); |
| 95 | } | 114 | await writeFileAtomically(file, JSON.stringify(encodeQueuedThreadMessage(message))); |
| 96 | file.write(JSON.stringify(encodeQueuedThreadMessage(message))); | 115 | })(), |
| 116 | ); | ||
| 97 | } catch (cause) { | 117 | } catch (cause) { |
| 98 | throw new ThreadOutboxStorageError({ | 118 | throw new ThreadOutboxStorageError({ |
| 99 | operation: "write", | 119 | operation: "write", |
apps/mobile/src/state/thread-outbox.ts+12-1| ... | @@ -3,7 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; | ... | @@ -3,7 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { appAtomRegistry } from "./atom-registry"; | 3 | import { appAtomRegistry } from "./atom-registry"; |
| 4 | import { createThreadOutboxManager } from "./thread-outbox-manager"; | 4 | import { createThreadOutboxManager } from "./thread-outbox-manager"; |
| 5 | import type { QueuedThreadMessage } from "./thread-outbox-model"; | 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 | export * from "./thread-outbox-model"; | 8 | export * from "./thread-outbox-model"; |
| 9 | 9 | ||
| ... | @@ -12,6 +12,17 @@ export const threadOutboxManager = createThreadOutboxManager({ | ... | @@ -12,6 +12,17 @@ export const threadOutboxManager = createThreadOutboxManager({ |
| 12 | storage: expoThreadOutboxStorage, | 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 | export function ensureThreadOutboxLoaded(): void { | 26 | export function ensureThreadOutboxLoaded(): void { |
| 16 | void threadOutboxManager.load(); | 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,6 +4,7 @@ import { vi } from "vite-plus/test"; |
| 4 | 4 | ||
| 5 | const composerDraftFileMocks = vi.hoisted(() => { | 5 | const composerDraftFileMocks = vi.hoisted(() => { |
| 6 | let document = ""; | 6 | let document = ""; |
| 7 | let writeError: Error | null = null; | ||
| 7 | let releaseRead: (() => void) | null = null; | 8 | let releaseRead: (() => void) | null = null; |
| 8 | let readBarrier = Promise.resolve(); | 9 | let readBarrier = Promise.resolve(); |
| 9 | 10 | ||
| ... | @@ -17,23 +18,35 @@ const composerDraftFileMocks = vi.hoisted(() => { | ... | @@ -17,23 +18,35 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 17 | releaseRead?.(); | 18 | releaseRead?.(); |
| 18 | releaseRead = null; | 19 | releaseRead = null; |
| 19 | }, | 20 | }, |
| 21 | getDocument() { | ||
| 22 | return document; | ||
| 23 | }, | ||
| 20 | setDocument(value: unknown) { | 24 | setDocument(value: unknown) { |
| 21 | document = JSON.stringify(value); | 25 | document = JSON.stringify(value); |
| 22 | }, | 26 | }, |
| 27 | setWriteError(error: Error | null) { | ||
| 28 | writeError = error; | ||
| 29 | }, | ||
| 23 | Directory: class { | 30 | Directory: class { |
| 24 | create() {} | 31 | create() {} |
| 25 | }, | 32 | }, |
| 26 | File: class { | 33 | File: class { |
| 27 | exists = true; | 34 | exists = true; |
| 35 | parentDirectory = null; | ||
| 28 | 36 | ||
| 29 | create() {} | 37 | create() {} |
| 30 | 38 | ||
| 39 | moveSync() {} | ||
| 40 | |||
| 31 | async text() { | 41 | async text() { |
| 32 | await readBarrier; | 42 | await readBarrier; |
| 33 | return document; | 43 | return document; |
| 34 | } | 44 | } |
| 35 | 45 | ||
| 36 | write(value: string) { | 46 | write(value: string) { |
| 47 | if (writeError) { | ||
| 48 | throw writeError; | ||
| 49 | } | ||
| 37 | document = value; | 50 | document = value; |
| 38 | } | 51 | } |
| 39 | }, | 52 | }, |
| ... | @@ -49,15 +62,18 @@ vi.mock("expo-file-system", () => ({ | ... | @@ -49,15 +62,18 @@ vi.mock("expo-file-system", () => ({ |
| 49 | import { appAtomRegistry } from "./atom-registry"; | 62 | import { appAtomRegistry } from "./atom-registry"; |
| 50 | import { | 63 | import { |
| 51 | clearComposerDraftContentState, | 64 | clearComposerDraftContentState, |
| 65 | ComposerDraftPersistenceError, | ||
| 52 | composerDraftsAtom, | 66 | composerDraftsAtom, |
| 53 | copyComposerDraftContentIfEmpty, | 67 | copyComposerDraftContentIfEmpty, |
| 54 | copyComposerDraftContentState, | 68 | copyComposerDraftContentState, |
| 55 | decodePersistedComposerDrafts, | 69 | decodePersistedComposerDrafts, |
| 56 | type ComposerDraft, | 70 | type ComposerDraft, |
| 71 | flushComposerDrafts, | ||
| 57 | getComposerDraftSnapshot, | 72 | getComposerDraftSnapshot, |
| 58 | mergeComposerDraftContentState, | 73 | mergeComposerDraftContentState, |
| 59 | removeComposerDraftsForEnvironment, | 74 | removeComposerDraftsForEnvironment, |
| 60 | restoreComposerDraftSnapshotState, | 75 | restoreComposerDraftSnapshotState, |
| 76 | setComposerDraftText, | ||
| 61 | } from "./use-composer-drafts"; | 77 | } from "./use-composer-drafts"; |
| 62 | 78 | ||
| 63 | const DRAFT: ComposerDraft = { | 79 | const DRAFT: ComposerDraft = { |
| ... | @@ -393,4 +409,27 @@ describe("mobile composer drafts", () => { | ... | @@ -393,4 +409,27 @@ describe("mobile composer drafts", () => { |
| 393 | [unrelatedKey]: unrelated, | 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,6 +13,7 @@ import * as Schema from "effect/Schema"; |
| 13 | import { useEffect } from "react"; | 13 | import { useEffect } from "react"; |
| 14 | import { Atom } from "effect/unstable/reactivity"; | 14 | import { Atom } from "effect/unstable/reactivity"; |
| 15 | 15 | ||
| 16 | import { writeFileAtomically } from "../lib/atomic-file"; | ||
| 16 | import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; | 17 | import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; |
| 17 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | 18 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 18 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; | 19 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; |
| ... | @@ -188,10 +189,7 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft | ... | @@ -188,10 +189,7 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft |
| 188 | } as const; | 189 | } as const; |
| 189 | const encoded = JSON.stringify(document); | 190 | const encoded = JSON.stringify(document); |
| 190 | operation = "write"; | 191 | operation = "write"; |
| 191 | if (!file.exists) { | 192 | await writeFileAtomically(file, encoded); |
| 192 | file.create({ intermediates: true, overwrite: true }); | ||
| 193 | } | ||
| 194 | file.write(encoded); | ||
| 195 | } catch (cause) { | 193 | } catch (cause) { |
| 196 | throw new ComposerDraftPersistenceError({ | 194 | throw new ComposerDraftPersistenceError({ |
| 197 | operation, | 195 | operation, |
| ... | @@ -211,6 +209,26 @@ async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft> | ... | @@ -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 | function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): void { | 232 | function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): void { |
| 215 | if (persistTimer !== null) { | 233 | if (persistTimer !== null) { |
| 216 | clearTimeout(persistTimer); | 234 | clearTimeout(persistTimer); |
| ... | @@ -627,7 +645,7 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI | ... | @@ -627,7 +645,7 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI |
| 627 | persistTimer = null; | 645 | persistTimer = null; |
| 628 | } | 646 | } |
| 629 | appAtomRegistry.set(composerDraftsAtom, next); | 647 | appAtomRegistry.set(composerDraftsAtom, next); |
| 630 | await writePersistedComposerDrafts(next); | 648 | await persistenceQueue.run(() => writePersistedComposerDrafts(next)); |
| 631 | } | 649 | } |
| 632 | 650 | ||
| 633 | export function useComposerDraft(draftKey: string | null): ComposerDraft { | 651 | export function useComposerDraft(draftKey: string | null): ComposerDraft { |
docs/user/updating.md+8| ... | @@ -70,4 +70,12 @@ If a step fails: | ... | @@ -70,4 +70,12 @@ If a step fails: |
| 70 | 3. For a command-line server, relaunch it with `npx t3@<client-version>`, replacing | 70 | 3. For a command-line server, relaunch it with `npx t3@<client-version>`, replacing |
| 71 | `<client-version>` with the client version shown in the warning. | 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 | For remote connection setup and access troubleshooting, see [Remote Access](./remote-access.md). | 81 | For remote connection setup and access troubleshooting, see [Remote Access](./remote-access.md). |