also looking at this
fix(ios): fall back to the bundle only when the remote is out of reach, and come back
#253
4 files
+206
-1
| @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' | ||
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { Glob } from 'bun' |
| 5 | 5 | import { bootSimulator, init, pickSimulator, type CraftConfig } from '../src/index' |
| 6 | import { checkLoadFailureClassifier } from './load-failure' | |
| 6 | 7 | import { watchCompanionProblems } from './watch-bundle' |
| 7 | 8 | |
| 8 | 9 | interface CompileFixture { |
| @@ -177,7 +178,10 @@ function buildTarget(output: string, projectName: string, targetName: string, sd | ||
| 177 | 178 | ], output, `Compiling ${targetName} against ${sdk}`) |
| 178 | 179 | } |
| 179 | 180 | |
| 181 | ||
| 180 | 182 | try { |
| 183 | checkLoadFailureClassifier(workspace, run) | |
| 184 | ||
| 181 | 185 | for (const fixture of fixtures) { |
| 182 | 186 | const output = join(workspace, fixture.name) |
| 183 | 187 | await init({ |
| @@ -0,0 +1,87 @@ | ||
| 1 | import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' | |
| 2 | import { join } from 'node:path' | |
| 3 | ||
| 4 | /** | |
| 5 | * Run the page-load failure classifier for real, not just read it (#252). | |
| 6 | * | |
| 7 | * `CraftLoadFailure` decides whether a failed load sends the app to its | |
| 8 | * bundled copy, and the bug it fixes is a single wrong answer: -999, a load a | |
| 9 | * newer one replaced, treated as the network being gone. `index.test.ts` can | |
| 10 | * only check its text. This lifts the enum out of the template, compiles it | |
| 11 | * with Foundation alone on this host, and asks it about real error codes. | |
| 12 | */ | |
| 13 | export function checkLoadFailureClassifier( | |
| 14 | workspace: string, | |
| 15 | run: (args: string[], cwd: string, label: string) => void, | |
| 16 | ): void { | |
| 17 | const template = readFileSync(join(import.meta.dir, '../templates/CraftApp.swift'), 'utf8') | |
| 18 | const start = template.indexOf('enum CraftLoadFailure {') | |
| 19 | if (start === -1) | |
| 20 | throw new Error('CraftApp.swift no longer declares enum CraftLoadFailure') | |
| 21 | let depth = 0 | |
| 22 | let end = start | |
| 23 | for (let i = start; i < template.length; i++) { | |
| 24 | if (template[i] === '{') depth++ | |
| 25 | else if (template[i] === '}' && --depth === 0) { | |
| 26 | end = i + 1 | |
| 27 | break | |
| 28 | } | |
| 29 | } | |
| 30 | const classifier = template.slice(start, end) | |
| 31 | ||
| 32 | const dir = join(workspace, 'load-failure') | |
| 33 | const main = join(dir, 'main.swift') | |
| 34 | mkdirSync(dir, { recursive: true }) | |
| 35 | writeFileSync(main, `import Foundation | |
| 36 | ||
| 37 | ${classifier} | |
| 38 | ||
| 39 | func check(_ label: String, _ error: NSError, _ fallsBack: Bool) { | |
| 40 | let got = CraftLoadFailure.isUnreachable(error) | |
| 41 | guard got == fallsBack else { | |
| 42 | print("FAIL: \\(label): isUnreachable = \\(got), expected \\(fallsBack)") | |
| 43 | exit(1) | |
| 44 | } | |
| 45 | print("ok: \\(label) -> \\(fallsBack ? "falls back to the bundle" : "stays on the remote")") | |
| 46 | } | |
| 47 | func url(_ code: Int) -> NSError { NSError(domain: NSURLErrorDomain, code: code) } | |
| 48 | ||
| 49 | // #252: a load a newer one replaced is not a failure. | |
| 50 | check("NSURLErrorCancelled (-999)", url(NSURLErrorCancelled), false) | |
| 51 | check("WebKitErrorDomain 102, frame load interrupted", NSError(domain: "WebKitErrorDomain", code: 102), false) | |
| 52 | ||
| 53 | // The server was reached. Swapping in the bundle would hide the fault. | |
| 54 | check("NSURLErrorSecureConnectionFailed", url(NSURLErrorSecureConnectionFailed), false) | |
| 55 | check("NSURLErrorServerCertificateUntrusted", url(NSURLErrorServerCertificateUntrusted), false) | |
| 56 | check("NSURLErrorBadServerResponse", url(NSURLErrorBadServerResponse), false) | |
| 57 | ||
| 58 | // Out of reach: the only case the bundled copy stands in for. | |
| 59 | check("NSURLErrorNotConnectedToInternet", url(NSURLErrorNotConnectedToInternet), true) | |
| 60 | check("NSURLErrorCannotFindHost", url(NSURLErrorCannotFindHost), true) | |
| 61 | check("NSURLErrorCannotConnectToHost", url(NSURLErrorCannotConnectToHost), true) | |
| 62 | check("NSURLErrorTimedOut", url(NSURLErrorTimedOut), true) | |
| 63 | check("NSURLErrorNetworkConnectionLost", url(NSURLErrorNetworkConnectionLost), true) | |
| 64 | check("NSURLErrorDNSLookupFailed", url(NSURLErrorDNSLookupFailed), true) | |
| 65 | `) | |
| 66 | run(['swiftc', '-o', join(dir, 'load-failure'), main], dir, 'Compiling the page-load failure classifier on its own') | |
| 67 | run([join(dir, 'load-failure')], dir, 'Asking it about real error codes') | |
| 68 | } | |
| 69 | ||
| 70 | // Runnable on its own, without the xcodebuild fixtures that make up the rest | |
| 71 | // of `test:templates`: `bun packages/ios/scripts/load-failure.ts`. | |
| 72 | if (import.meta.main) { | |
| 73 | const { mkdtempSync, rmSync } = await import('node:fs') | |
| 74 | const { tmpdir } = await import('node:os') | |
| 75 | const workspace = mkdtempSync(join(tmpdir(), 'craft-load-failure-')) | |
| 76 | try { | |
| 77 | checkLoadFailureClassifier(workspace, (args, cwd, label) => { | |
| 78 | console.log(`\n${label}`) | |
| 79 | const result = Bun.spawnSync(args, { cwd, stdout: 'inherit', stderr: 'inherit' }) | |
| 80 | if (result.exitCode !== 0) | |
| 81 | throw new Error(`${label} exited with ${result.exitCode}`) | |
| 82 | }) | |
| 83 | } | |
| 84 | finally { | |
| 85 | rmSync(workspace, { force: true, recursive: true }) | |
| 86 | } | |
| 87 | } | |
| @@ -318,6 +318,37 @@ describe('Craft iOS builder', () => { | ||
| 318 | 318 | expect(receive).toContain('sendToWeb("craftPushToken", data: ["token": token])') |
| 319 | 319 | }) |
| 320 | 320 | |
| 321 | it('falls back to the bundle only when the remote is out of reach, and comes back', async () => { | |
| 322 | // #252: any failed or cancelled main-frame load sent the app to its bundled | |
| 323 | // copy for the rest of the process. The classifier's answers are run for | |
| 324 | // real by scripts/load-failure.ts; this pins that it is actually consulted, | |
| 325 | // and that the way back is wired. | |
| 326 | const output = mkdtempSync(join(tmpdir(), 'craft-ios-load-failure-')) | |
| 327 | await init({ name: 'WildLoop', bundleId: 'org.wildloop.app', output }) | |
| 328 | const swift = readFileSync(join(output, 'Sources', 'WildLoopApp.swift'), 'utf8') | |
| 329 | ||
| 330 | // Both failure callbacks go through the classifier, and neither reaches | |
| 331 | // the fallback directly any more — which is the whole bug. | |
| 332 | const failures = swift.slice( | |
| 333 | swift.indexOf('func webView(_ webView: WKWebView, didFailProvisionalNavigation'), | |
| 334 | swift.indexOf('private func fallBackIfUnreachable('), | |
| 335 | ) | |
| 336 | expect(failures.match(/fallBackIfUnreachable\(webView, after: error\)/g)?.length).toBe(2) | |
| 337 | expect(failures).not.toContain('loadBundledFallback(in: webView)') | |
| 338 | expect(swift).toContain('guard CraftLoadFailure.isUnreachable(error) else { return }') | |
| 339 | ||
| 340 | // No longer one-way: two ways back to the remote origin. | |
| 341 | expect(swift).toContain('private func returnFromBundledFallback(because reason: String)') | |
| 342 | expect(swift).toContain('loadedBundledFallback = false') | |
| 343 | expect(swift).toContain('name: UIApplication.willEnterForegroundNotification') | |
| 344 | ||
| 345 | // And the network trigger retries on a transition only. Retrying whenever | |
| 346 | // the path is satisfied would loop against a server that is down on a | |
| 347 | // network that is up. | |
| 348 | expect(swift).toContain('let wasConnected = self?.isConnected ?? true') | |
| 349 | expect(swift).toContain('if !wasConnected, self?.isConnected == true {') | |
| 350 | }) | |
| 351 | ||
| 321 | 352 | it('settles calendar callbacks only after a real EventKit operation', async () => { |
| 322 | 353 | const output = mkdtempSync(join(tmpdir(), 'craft-ios-calendar-')) |
| 323 | 354 | await init({ |
| @@ -37,6 +37,42 @@ extension Notification.Name { | ||
| 37 | 37 | static let craftNotificationResponse = Notification.Name("craftNotificationResponse") |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | /// Which failed page loads mean the remote origin is out of reach (#252). | |
| 41 | /// | |
| 42 | /// That is the only failure the bundled copy stands in for. Anything else | |
| 43 | /// either is not a failure at all or is one the bundle would hide: a TLS error | |
| 44 | /// or a bad response means the server *was* reached, and swapping in a local | |
| 45 | /// copy would bury a real fault under a page that half-works. | |
| 46 | /// | |
| 47 | /// Written against NSError's domain and code rather than WebKit's types, so it | |
| 48 | /// compiles with Foundation alone — which is how `compile-templates` runs it on | |
| 49 | /// its own, on the CI host, rather than only reading its text. | |
| 50 | enum CraftLoadFailure { | |
| 51 | static func isUnreachable(_ error: Error) -> Bool { | |
| 52 | let error = error as NSError | |
| 53 | // Everything outside NSURLErrorDomain is excluded, WebKit's own | |
| 54 | // frame-load-interrupted (WebKitErrorDomain 102) among them: a load | |
| 55 | // replaced by a newer one is not a load that failed. | |
| 56 | guard error.domain == NSURLErrorDomain else { return false } | |
| 57 | switch error.code { | |
| 58 | case NSURLErrorNotConnectedToInternet, | |
| 59 | NSURLErrorCannotFindHost, | |
| 60 | NSURLErrorCannotConnectToHost, | |
| 61 | NSURLErrorTimedOut, | |
| 62 | NSURLErrorNetworkConnectionLost, | |
| 63 | NSURLErrorDNSLookupFailed, | |
| 64 | NSURLErrorDataNotAllowed, | |
| 65 | NSURLErrorInternationalRoamingOff: | |
| 66 | return true | |
| 67 | default: | |
| 68 | // NSURLErrorCancelled (-999) lands here. WebKit reports it for two | |
| 69 | // quick taps, a redirect, or a `location.assign` while a load is in | |
| 70 | // flight — and it used to end the session on the bundled copy. | |
| 71 | return false | |
| 72 | } | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 40 | 76 | final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { |
| 41 | 77 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { |
| 42 | 78 | UNUserNotificationCenter.current().delegate = self |
| @@ -600,6 +636,7 @@ struct CraftWebView: UIViewRepresentable { | ||
| 600 | 636 | NotificationCenter.default.addObserver(self, selector: #selector(receivePushToken(_:)), name: .craftPushToken, object: nil) |
| 601 | 637 | NotificationCenter.default.addObserver(self, selector: #selector(receivePushRegistrationError(_:)), name: .craftPushRegistrationError, object: nil) |
| 602 | 638 | NotificationCenter.default.addObserver(self, selector: #selector(receiveNotificationResponse(_:)), name: .craftNotificationResponse, object: nil) |
| 639 | NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) | |
| 603 | 640 | setupNetworkMonitoring() |
| 604 | 641 | } |
| 605 | 642 | |
| @@ -622,6 +659,9 @@ struct CraftWebView: UIViewRepresentable { | ||
| 622 | 659 | private func setupNetworkMonitoring() { |
| 623 | 660 | networkMonitor = NWPathMonitor() |
| 624 | 661 | networkMonitor?.pathUpdateHandler = { [weak self] path in |
| 662 | // Read before it is overwritten: the retry below wants the | |
| 663 | // transition, not the state. | |
| 664 | let wasConnected = self?.isConnected ?? true | |
| 625 | 665 | self?.isConnected = path.status == .satisfied |
| 626 | 666 | if path.usesInterfaceType(.wifi) { |
| 627 | 667 | self?.connectionType = "wifi" |
| @@ -636,6 +676,16 @@ struct CraftWebView: UIViewRepresentable { | ||
| 636 | 676 | "isConnected": self?.isConnected ?? false, |
| 637 | 677 | "type": self?.connectionType ?? "unknown" |
| 638 | 678 | ]) |
| 679 | // Only on false → true. Retrying whenever the path is merely | |
| 680 | // satisfied would loop: a server that is down on a network | |
| 681 | // that is up leaves the path satisfied throughout, so every | |
| 682 | // update would retry, fail, fall back and retry again. That | |
| 683 | // case recovers on the next foreground instead. | |
| 684 | if !wasConnected, self?.isConnected == true { | |
| 685 | DispatchQueue.main.async { | |
| 686 | self?.returnFromBundledFallback(because: "the network came back") | |
| 687 | } | |
| 688 | } | |
| 639 | 689 | } |
| 640 | 690 | networkMonitor?.start(queue: DispatchQueue.global()) |
| 641 | 691 | } |
| @@ -1497,10 +1547,17 @@ struct CraftWebView: UIViewRepresentable { | ||
| 1497 | 1547 | } |
| 1498 | 1548 | |
| 1499 | 1549 | func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { |
| 1500 | loadBundledFallback(in: webView) | |
| 1550 | fallBackIfUnreachable(webView, after: error) | |
| 1501 | 1551 | } |
| 1502 | 1552 | |
| 1503 | 1553 | func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { |
| 1554 | fallBackIfUnreachable(webView, after: error) | |
| 1555 | } | |
| 1556 | ||
| 1557 | /// Both used to fall back on *any* error, -999 included, so a second | |
| 1558 | /// tap before the first page committed ended the session on the bundle. | |
| 1559 | private func fallBackIfUnreachable(_ webView: WKWebView, after error: Error) { | |
| 1560 | guard CraftLoadFailure.isUnreachable(error) else { return } | |
| 1504 | 1561 | loadBundledFallback(in: webView) |
| 1505 | 1562 | } |
| 1506 | 1563 | |
| @@ -1509,9 +1566,35 @@ struct CraftWebView: UIViewRepresentable { | ||
| 1509 | 1566 | config.devServerURL != nil, |
| 1510 | 1567 | let bundledURL = URL(string: "craft://app/index.html") else { return } |
| 1511 | 1568 | loadedBundledFallback = true |
| 1569 | // Kept for the way back: `returnFromBundledFallback` has no view of | |
| 1570 | // its own to reload. | |
| 1571 | self.webView = webView | |
| 1512 | 1572 | webView.load(URLRequest(url: bundledURL)) |
| 1513 | 1573 | } |
| 1514 | 1574 | |
| 1575 | /// Leave the bundled copy for the remote origin again (#252). | |
| 1576 | /// | |
| 1577 | /// It used to be one-way for the life of the process, and the bundle | |
| 1578 | /// cannot reach a frontend's API by relative path — so one dropped | |
| 1579 | /// connection meant no API until the app was killed. | |
| 1580 | /// | |
| 1581 | /// Called when connectivity comes back and when the app returns to the | |
| 1582 | /// foreground, never on every path update. If the remote is still out | |
| 1583 | /// of reach, the load fails with a connectivity error and falls back | |
| 1584 | /// again: one round trip per trigger, not a loop. | |
| 1585 | private func returnFromBundledFallback(because reason: String) { | |
| 1586 | guard loadedBundledFallback, | |
| 1587 | let webView, | |
| 1588 | let remote = config.devServerURL.flatMap(URL.init(string:)) else { return } | |
| 1589 | loadedBundledFallback = false | |
| 1590 | print("Craft: \(reason); loading \(remote) again instead of the bundled copy") | |
| 1591 | webView.load(URLRequest(url: remote)) | |
| 1592 | } | |
| 1593 | ||
| 1594 | @objc private func appWillEnterForeground(_ notification: Notification) { | |
| 1595 | returnFromBundledFallback(because: "the app returned to the foreground") | |
| 1596 | } | |
| 1597 | ||
| 1515 | 1598 | private func isTrustedURL(_ url: URL?) -> Bool { |
| 1516 | 1599 | guard let url = url else { return false } |
| 1517 | 1600 | return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0) |