ReviewOS

also looking at this

craft-native/craft

fix(ios): fall back to the bundle only when the remote is out of reach, and come back

#253
Merged glennmichael123 wants to merge fix/ios-fallback-only-for-connectivity into main
4 files +206 -1
packages/ios/scripts/compile-templates.tsmodified+4-0
Changes to packages/ios/scripts/compile-templates.ts
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
33import { join } from 'node:path'
44import { Glob } from 'bun'
55import { bootSimulator, init, pickSimulator, type CraftConfig } from '../src/index'
6import { checkLoadFailureClassifier } from './load-failure'
67import { watchCompanionProblems } from './watch-bundle'
78
89interface CompileFixture {
@@ -177,7 +178,10 @@ function buildTarget(output: string, projectName: string, targetName: string, sd
177178 ], output, `Compiling ${targetName} against ${sdk}`)
178179}
179180
181
180182try {
183 checkLoadFailureClassifier(workspace, run)
184
181185 for (const fixture of fixtures) {
182186 const output = join(workspace, fixture.name)
183187 await init({
packages/ios/scripts/load-failure.tsadded+87-0
Changes to packages/ios/scripts/load-failure.ts
@@ -0,0 +1,87 @@
1import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2import { 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 */
13export 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
39func 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}
47func url(_ code: Int) -> NSError { NSError(domain: NSURLErrorDomain, code: code) }
48
49// #252: a load a newer one replaced is not a failure.
50check("NSURLErrorCancelled (-999)", url(NSURLErrorCancelled), false)
51check("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.
54check("NSURLErrorSecureConnectionFailed", url(NSURLErrorSecureConnectionFailed), false)
55check("NSURLErrorServerCertificateUntrusted", url(NSURLErrorServerCertificateUntrusted), false)
56check("NSURLErrorBadServerResponse", url(NSURLErrorBadServerResponse), false)
57
58// Out of reach: the only case the bundled copy stands in for.
59check("NSURLErrorNotConnectedToInternet", url(NSURLErrorNotConnectedToInternet), true)
60check("NSURLErrorCannotFindHost", url(NSURLErrorCannotFindHost), true)
61check("NSURLErrorCannotConnectToHost", url(NSURLErrorCannotConnectToHost), true)
62check("NSURLErrorTimedOut", url(NSURLErrorTimedOut), true)
63check("NSURLErrorNetworkConnectionLost", url(NSURLErrorNetworkConnectionLost), true)
64check("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`.
72if (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}
packages/ios/src/index.test.tsmodified+31-0
Changes to packages/ios/src/index.test.ts
@@ -318,6 +318,37 @@ describe('Craft iOS builder', () => {
318318 expect(receive).toContain('sendToWeb("craftPushToken", data: ["token": token])')
319319 })
320320
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
321352 it('settles calendar callbacks only after a real EventKit operation', async () => {
322353 const output = mkdtempSync(join(tmpdir(), 'craft-ios-calendar-'))
323354 await init({
packages/ios/templates/CraftApp.swiftmodified+84-1
Changes to packages/ios/templates/CraftApp.swift
@@ -37,6 +37,42 @@ extension Notification.Name {
3737 static let craftNotificationResponse = Notification.Name("craftNotificationResponse")
3838}
3939
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.
50enum 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
4076final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
4177 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
4278 UNUserNotificationCenter.current().delegate = self
@@ -600,6 +636,7 @@ struct CraftWebView: UIViewRepresentable {
600636 NotificationCenter.default.addObserver(self, selector: #selector(receivePushToken(_:)), name: .craftPushToken, object: nil)
601637 NotificationCenter.default.addObserver(self, selector: #selector(receivePushRegistrationError(_:)), name: .craftPushRegistrationError, object: nil)
602638 NotificationCenter.default.addObserver(self, selector: #selector(receiveNotificationResponse(_:)), name: .craftNotificationResponse, object: nil)
639 NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
603640 setupNetworkMonitoring()
604641 }
605642
@@ -622,6 +659,9 @@ struct CraftWebView: UIViewRepresentable {
622659 private func setupNetworkMonitoring() {
623660 networkMonitor = NWPathMonitor()
624661 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
625665 self?.isConnected = path.status == .satisfied
626666 if path.usesInterfaceType(.wifi) {
627667 self?.connectionType = "wifi"
@@ -636,6 +676,16 @@ struct CraftWebView: UIViewRepresentable {
636676 "isConnected": self?.isConnected ?? false,
637677 "type": self?.connectionType ?? "unknown"
638678 ])
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 }
639689 }
640690 networkMonitor?.start(queue: DispatchQueue.global())
641691 }
@@ -1497,10 +1547,17 @@ struct CraftWebView: UIViewRepresentable {
14971547 }
14981548
14991549 func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
1500 loadBundledFallback(in: webView)
1550 fallBackIfUnreachable(webView, after: error)
15011551 }
15021552
15031553 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 }
15041561 loadBundledFallback(in: webView)
15051562 }
15061563
@@ -1509,9 +1566,35 @@ struct CraftWebView: UIViewRepresentable {
15091566 config.devServerURL != nil,
15101567 let bundledURL = URL(string: "craft://app/index.html") else { return }
15111568 loadedBundledFallback = true
1569 // Kept for the way back: `returnFromBundledFallback` has no view of
1570 // its own to reload.
1571 self.webView = webView
15121572 webView.load(URLRequest(url: bundledURL))
15131573 }
15141574
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
15151598 private func isTrustedURL(_ url: URL?) -> Bool {
15161599 guard let url = url else { return false }
15171600 return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0)