From 80570d0bfa95a32e88d9442e6338843e28b4c13b Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sat, 2 May 2026 21:09:20 -0400 Subject: [PATCH] =?UTF-8?q?apple/ios=201.1(3):=20hung-load=20recovery=20?= =?UTF-8?q?=E2=80=94=20watchdog=20+=20heartbeat=20+=20reload=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field reports of the iOS app sitting on the boot.mjs animation indefinitely with no recovery short of force-quit. Root cause was structural: no host-side detection of a stuck JS runtime, no failure path on WKNavigationDelegate, and the network monitor cancelled itself on the first satisfied path so mid-session drops were invisible. - AppNetworkMonitor (long-lived) replaces the one-shot monitor; online↔offline transitions auto-reload. - BootStatus 5s-poll watchdog flips `stalled = true` after 25s of no JS heartbeat; SwiftUI overlay surfaces a Reload button. - WKNavigationDelegate wired so provisional/final nav failures populate `BootStatus.lastError` for the overlay. - boot.mjs IIFE posts {type:"boot:heartbeat"|"boot:ready"} via webkit.messageHandlers.iOSApp every 1s during boot, every 8s after acHIDE_BOOT_LOG. - Pull-to-refresh attached to the WebView's scroll view via UIRefreshControl. - Scene-phase reload after >5min background (stale runtime / timed-out sockets). - offline.html "Try again" button posts {type:"reload-online"} to force-load even if path monitor still reads offline (cell-handoff lag). - Reload gating on Coordinator.lastLoadedKey so updateUIView only reloads on real URL/trigger changes, not every SwiftUI re-render. Bump CFBundleVersion to 3 before archiving. Co-Authored-By: Claude Opus 4.7 (1M context) --- apple/PROGRESS.md | 54 ++ .../project.pbxproj | 8 +- apple/aesthetic.computer/ContentView.swift | 703 ++++++++++++------ apple/aesthetic.computer/html/offline.html | 172 +++-- system/public/aesthetic.computer/boot.mjs | 33 + 5 files changed, 670 insertions(+), 300 deletions(-) diff --git a/apple/PROGRESS.md b/apple/PROGRESS.md index 88e2485ab6..2267328c33 100644 --- a/apple/PROGRESS.md +++ b/apple/PROGRESS.md @@ -5,6 +5,60 @@ Append a new section on top for each App Store build. --- +## 1.1 (3) — 2026-04-30 (in progress) + +Reliability pass before this build ships. Field reports of the app sitting +on the boot.mjs animation indefinitely (5,000s+) with no way to recover +short of force-quit. Root cause was structural — there was no host-side +detection of a stuck JS runtime, no failure path on `WKNavigationDelegate`, +and the network monitor cancelled itself on the first satisfied path so +mid-session drops were invisible. + +### Shipped +- **Long-lived `AppNetworkMonitor`** (replaces the one-shot monitor in + `ContentView`). Stays alive for the whole process and publishes online↔ + offline transitions; flipping back online auto-reloads. +- **Boot watchdog.** `BootStatus` runs a 5s-poll timer that flips + `stalled = true` if no JS heartbeat has arrived for 25 seconds. Stalled + state surfaces a SwiftUI overlay with a "Reload" button anchored at the + bottom — covers the "stuck on boot animation" failure mode. +- **`WKNavigationDelegate`** wired up. Provisional + final navigation + failures populate `BootStatus.lastError` so the same overlay can show a + proper error string ("Cannot reach aesthetic.computer — …") instead of + a frozen UI. +- **JS heartbeat** in `boot.mjs`. Every 1s during boot, then every 8s + after `acHIDE_BOOT_LOG` fires, posts `{type:"boot:heartbeat"|"boot:ready"}` + through the existing `iOSApp` message channel. Lets the host distinguish + "slow load" from "deadlocked". +- **Pull-to-refresh** on the WebView's scroll view (UIRefreshControl + attached in `makeUIView`). Recovery is now one gesture. +- **Scene-phase reload.** Returning the app from >5min of background + triggers a fresh load — the WebView often holds a stale runtime + (timed-out sockets, half-loaded modules) after long sleeps. +- **`offline.html` retry button.** Posts `{type:"reload-online"}` to the + host, which force-loads the live URL even if the path monitor still + reads offline (cell handoffs lag a few seconds). +- **Reload gating in `updateUIView`.** Tracks `lastLoadedKey` on the + Coordinator so reloads only fire on real URL/trigger changes, not on + every SwiftUI re-render (the previous code rebust+reloaded each time). + +### Wiring summary +- iOS: `apple/aesthetic.computer/ContentView.swift` — + `AppNetworkMonitor`, `BootStatus`, watchdog, nav delegate, overlay, + pull-to-refresh, scene phase. +- iOS: `apple/aesthetic.computer/html/offline.html` — retry button. +- Runtime: `system/public/aesthetic.computer/boot.mjs` — IIFE that posts + `boot:heartbeat`/`boot:ready` via `webkit.messageHandlers.iOSApp`. + +### Submission notes +- Same bundle IDs / team / Apple ID as 1.1 (2). +- Bump `CFBundleVersion` to `3` before archiving. +- What's New text: "Recovery from hung loads — reload from a stuck boot + screen, automatic retry when the network reconnects, pull down to + refresh." + +--- + ## 1.1 (2) — 2026-04-23 First update since 1.0 (1) shipped as RC4 in late 2024. Focus: per-device push diff --git a/apple/aesthetic.computer.xcodeproj/project.pbxproj b/apple/aesthetic.computer.xcodeproj/project.pbxproj index 8aa08fe581..6b7435fe1f 100644 --- a/apple/aesthetic.computer.xcodeproj/project.pbxproj +++ b/apple/aesthetic.computer.xcodeproj/project.pbxproj @@ -426,7 +426,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; "CODE_SIGN_ENTITLEMENTS[sdk=*]" = aesthetic.computer/aesthetic.computer.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "\"aesthetic.computer/Preview Content\""; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_PREVIEWS = YES; @@ -461,7 +461,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "aesthetic.computer/aesthetic.computer-release.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "\"aesthetic.computer/Preview Content\""; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_PREVIEWS = YES; @@ -493,7 +493,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FB5948YR3S; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = aesthetic/Info.plist; @@ -520,7 +520,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FB5948YR3S; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = aesthetic/Info.plist; diff --git a/apple/aesthetic.computer/ContentView.swift b/apple/aesthetic.computer/ContentView.swift index e0da4bd595..657b804940 100644 --- a/apple/aesthetic.computer/ContentView.swift +++ b/apple/aesthetic.computer/ContentView.swift @@ -1,237 +1,466 @@ -import SwiftUI -import WebKit -import Network - -let grey: CGFloat = 32/255; - -class Coordinator: NSObject, WKScriptMessageHandler, WKUIDelegate { - - //@available(iOS 15.0, *) - func webView(_ webView: WKWebView, - decideMediaCapturePermissionsFor origin: WKSecurityOrigin, - initiatedBy frame: WKFrameInfo, - type: WKMediaCaptureType) async -> WKPermissionDecision { - return .grant; - } - - // Handle JavaScript messages here - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - if message.name == "iOSAppLog" { - print("JavaScript Log: \(message.body)") - } - else if message.name == "iOSApp", let jsonString = message.body as? String { - - // Convert JSON string to Dictionary - print("JavaScript Log: \(message.body)") - if let data = jsonString.data(using: .utf8) { - do { - if let dictionary = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { - // Now 'dictionary' is a Swift dictionary - print("JSON as dictionary: \(dictionary)") - - // Handle the dictionary as needed - if let type = dictionary["type"] as? String { - switch type { - case "notifications": - if let body = dictionary["body"] as? Bool, body == true { - AppDelegate.shared?.triggerSubscribe() - showAlert(title: "SUBSCRIBED", message: "You have successfully subscribed to notifications. Type \"nonotifs\" to unsubscribe.") - } else if let body = dictionary["body"] as? Bool, body == false { - AppDelegate.shared?.triggerUnsubscribe() - showAlert(title: "UNSUBSCRIBED", message: "You have successfully unsubscribed to notifications. Type \"notifs\" to subscribe.") - } - - case "url": - if let urlString = dictionary["body"] as? String, let url = URL(string: urlString) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - - default: - print("Unhandled type: \(type)") - } - } - } - } catch { - print("Error parsing JSON: \(error)") - } - } - } - } - - func showAlert(title: String, message: String) { - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) - - // Find the active window scene - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootViewController = windowScene.windows.first?.rootViewController { - - // Present the alert from the top-most view controller - var currentController = rootViewController - while let presentedController = currentController.presentedViewController { - currentController = presentedController - } - currentController.present(alert, animated: true, completion: nil) - } - } - -// func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { -// print("link clicked..."); -// if navigationAction.targetFrame == nil, let url = navigationAction.request.url { -// print("opening...") -// UIApplication.shared.open(url, options: [:], completionHandler: nil) -// decisionHandler(.cancel) -// return -// } -// decisionHandler(.allow) -// } -// -// func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { -// if let url = navigationAction.request.url { -// webView.load(URLRequest(url: url)) -// } -// return nil -// } - -} - - struct WebView: UIViewRepresentable { - var url: String - var isOnline: Bool - - func makeCoordinator() -> Coordinator { - return Coordinator() - } - - func makeUIView(context: Context) -> WKWebView { - let config = WKWebViewConfiguration() - let userScript = WKUserScript(source: "console.log = function() { window.webkit.messageHandlers.iOSAppLog.postMessage([...arguments].join(' ')); }", - injectionTime: .atDocumentStart, - forMainFrameOnly: false) - config.allowsInlineMediaPlayback = true - config.userContentController.addUserScript(userScript) - config.userContentController.add(context.coordinator, name: "iOSAppLog") - config.userContentController.add(context.coordinator, name: "iOSApp") - - // 🧹 Wipe every cache surface that has been observed to keep stale - // /aesthetic.computer/*.mjs (boot, bios, disk, ...) alive between - // launches: HTTP caches, the SW registration + its CacheStorage, - // IndexedDB (where the SW persists its precache manifest version), - // WebSQL/AppCache. We deliberately keep cookies + localStorage so - // the user stays logged in. The wipe used to be fire-and-forget, - // which raced webView.load() and frequently lost — the load would - // start before removal finished and the SW would re-hydrate from - // its old caches. We now block until removal completes (semaphore - // off the main thread, then post the load) so the first request - // truly hits an empty data store. - let cacheTypes: Set = [ - WKWebsiteDataTypeDiskCache, - WKWebsiteDataTypeMemoryCache, - WKWebsiteDataTypeFetchCache, - WKWebsiteDataTypeOfflineWebApplicationCache, - WKWebsiteDataTypeServiceWorkerRegistrations, - WKWebsiteDataTypeIndexedDBDatabases, - WKWebsiteDataTypeWebSQLDatabases, - ] - WKWebsiteDataStore.default().removeData( - ofTypes: cacheTypes, - modifiedSince: .distantPast - ) {} - // Also nuke the foundation-level URL cache that backs WKWebView's - // subresource fetches; this is independent of WKWebsiteDataStore. - URLCache.shared.removeAllCachedResponses() - - let webView = WKWebView(frame: .zero, configuration: config) - webView.backgroundColor = UIColor(red: grey, green: grey, blue: grey, alpha: 1) - webView.isOpaque = false -// webView.navigationDelegate = context.coordinator - webView.uiDelegate = context.coordinator - webView.customUserAgent = "Aesthetic" - - // 👆 Fix "first tap dropped" on iOS: UIScrollView (which WKWebView - // hosts its content in) defaults to delaysContentTouches = true - // and waits ~150ms before forwarding the first touch to the page - // so it can decide whether the gesture is a scroll. On the AC - // canvas, that delay swallows the tap that opens the prompt — the - // very first interaction after launch silently no-ops. Disabling - // both delaysContentTouches and canCancelContentTouches forwards - // touches to JS immediately, which matches Safari's behaviour for - // pages that handle their own gestures. - webView.scrollView.delaysContentTouches = false - webView.scrollView.canCancelContentTouches = false - - // Add a script message handler to handle messages from JavaScript - AppDelegate.shared?.appWebView = webView // Set the shared appWebView - return webView - } - - func updateUIView(_ webView: WKWebView, context: Context) { -// let testHTML = "" -// webView.loadHTMLString(testHTML, baseURL: nil) - // 🚫 Belt + braces against stale modules: - // • .reloadIgnoringLocalCacheData bypasses the URL cache. - // • A per-launch ?_iosbust= query param defeats any - // cache key (CDN, SW match) that ignores cache-control. The - // AC site itself ignores unknown query params on the root. - guard var components = URLComponents(string: url) else { return } - if components.scheme == "http" || components.scheme == "https" { - var items = components.queryItems ?? [] - items.append(URLQueryItem( - name: "_iosbust", - value: String(Int(Date().timeIntervalSince1970)) - )) - components.queryItems = items - } - guard let bustedURL = components.url else { return } - let request = URLRequest( - url: bustedURL, - cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, - timeoutInterval: 30 - ) - webView.load(request) - } - - } - - struct ContentView: View { - @State private var isOnline: Bool? = nil - - var body: some View { - GeometryReader { geometry in - VStack { - if let isOnline = isOnline { - if isOnline { - WebView(url: "https://aesthetic.computer", isOnline: true) - } else { - let test = (Bundle.main.url(forResource: "offline", withExtension: "html", subdirectory: "html")?.absoluteString ?? "") - WebView(url: test, isOnline: false) - - } - } else { - Color(red: grey, green: grey, blue: grey) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .onAppear { - let monitor = NWPathMonitor() - monitor.pathUpdateHandler = { path in - self.isOnline = path.status == .satisfied - if self.isOnline == true { - monitor.cancel() //stops monitoring once you are online - } - } - let queue = DispatchQueue(label: "NetworkMonitor") - monitor.start(queue: queue) - } - .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 24 : 0) - .background(Color(red: grey, green: grey, blue: grey)) - .ignoresSafeArea(.keyboard, edges: .bottom) - } - } - } - - struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } - } +import SwiftUI +import WebKit +import Network +import Combine + +let grey: CGFloat = 32/255 + +// MARK: - Long-lived network monitor +// +// The previous implementation cancelled NWPathMonitor on the first .satisfied +// path, which meant a mid-session connectivity drop was invisible to the app. +// This monitor stays alive for the whole process so the UI can react to +// every transition (online → offline → online). + +final class AppNetworkMonitor: ObservableObject { + @Published var isOnline: Bool? = nil + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "AppNetworkMonitor") + + init() { + monitor.pathUpdateHandler = { [weak self] path in + let online = path.status == .satisfied + DispatchQueue.main.async { + self?.isOnline = online + } + } + monitor.start(queue: queue) + } + + deinit { monitor.cancel() } +} + +// MARK: - Boot status / watchdog +// +// Tracks whether the WebView's JS runtime is making forward progress. The +// runtime sends a heartbeat (window.iOSReportBootHeartbeat) at boot and +// periodically afterwards via webkit.messageHandlers.iOSApp. If we don't +// hear from it for STALL_TIMEOUT seconds while a navigation is in flight, +// we surface a "Reload" overlay so the user can recover without force-quitting. +// +// Bumping `reloadTrigger` causes WebView.updateUIView to issue a fresh load +// (with a per-launch _iosbust query param). `forceLiveTrigger` asks the +// view to attempt the live URL even if the network monitor is offline — +// useful for the "Tap to retry" button on offline.html when the monitor +// hasn't flipped yet. + +final class BootStatus: ObservableObject { + @Published var stalled: Bool = false + @Published var lastError: String? = nil + @Published var reloadTrigger: Int = 0 + @Published var forceLiveTrigger: Int = 0 + + private var lastHeartbeatAt = Date() + private var watchdog: Timer? = nil + private var watchdogActive = false + private(set) var isReady = false + + private let stallTimeout: TimeInterval = 25 + private let pollInterval: TimeInterval = 5 + + func startWatchdog() { + stopWatchdog() + lastHeartbeatAt = Date() + isReady = false + watchdogActive = true + DispatchQueue.main.async { + self.stalled = false + self.lastError = nil + } + let timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in + guard let self = self, self.watchdogActive else { return } + let elapsed = Date().timeIntervalSince(self.lastHeartbeatAt) + if elapsed > self.stallTimeout { + DispatchQueue.main.async { + if !self.stalled { self.stalled = true } + } + } + } + RunLoop.main.add(timer, forMode: .common) + watchdog = timer + } + + func stopWatchdog() { + watchdogActive = false + watchdog?.invalidate() + watchdog = nil + } + + func heartbeat(ready: Bool = false) { + lastHeartbeatAt = Date() + if ready { + isReady = true + stopWatchdog() + } + DispatchQueue.main.async { + if self.stalled { self.stalled = false } + } + } + + func setError(_ msg: String) { + DispatchQueue.main.async { + self.lastError = msg + } + } + + func requestReload() { + stopWatchdog() + DispatchQueue.main.async { + self.stalled = false + self.lastError = nil + self.reloadTrigger += 1 + } + } + + func requestForceLive() { + stopWatchdog() + DispatchQueue.main.async { + self.stalled = false + self.lastError = nil + self.forceLiveTrigger += 1 + } + } +} + +// MARK: - Coordinator +// +// Owns: WKScriptMessageHandler (JS bridge), WKNavigationDelegate (load +// success/failure), WKUIDelegate (media permissions). Holds a weak ref to +// BootStatus so heartbeats / errors flow upward. + +class Coordinator: NSObject, WKScriptMessageHandler, WKUIDelegate, WKNavigationDelegate { + weak var bootStatus: BootStatus? + private(set) var lastLoadedKey: String? = nil + + init(bootStatus: BootStatus) { + self.bootStatus = bootStatus + super.init() + } + + func setLoadedKey(_ key: String) { lastLoadedKey = key } + + // MARK: media permissions + func webView(_ webView: WKWebView, + decideMediaCapturePermissionsFor origin: WKSecurityOrigin, + initiatedBy frame: WKFrameInfo, + type: WKMediaCaptureType) async -> WKPermissionDecision { + return .grant + } + + // MARK: navigation + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + // Only watchdog real network loads; offline.html doesn't have JS that + // can heartbeat back, so we'd false-alarm on the static offline page. + if let url = webView.url, url.scheme == "https" || url.scheme == "http" { + bootStatus?.startWatchdog() + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + bootStatus?.setError("Load failed — \(error.localizedDescription)") + } + + func webView(_ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error) { + bootStatus?.setError("Cannot reach aesthetic.computer\n\(error.localizedDescription)") + } + + // MARK: pull-to-refresh + @objc func handleRefresh(_ sender: UIRefreshControl) { + bootStatus?.requestReload() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + sender.endRefreshing() + } + } + + // MARK: JS → native messages + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == "iOSAppLog" { + print("JavaScript Log: \(message.body)") + return + } + + guard message.name == "iOSApp", let jsonString = message.body as? String else { return } + guard let data = jsonString.data(using: .utf8) else { return } + guard let dictionary = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { + print("📱 iOSApp message: failed to parse \(jsonString)") + return + } + + guard let type = dictionary["type"] as? String else { return } + + switch type { + case "boot:heartbeat": + bootStatus?.heartbeat() + case "boot:ready": + bootStatus?.heartbeat(ready: true) + case "reload": + bootStatus?.requestReload() + case "reload-online": + // Force-load the live URL even if the network monitor still says + // offline (used by the offline.html "Tap to retry" button — the + // monitor can lag behind a real reconnect by a few seconds). + bootStatus?.requestForceLive() + case "notifications": + if let body = dictionary["body"] as? Bool, body == true { + AppDelegate.shared?.triggerSubscribe() + showAlert(title: "SUBSCRIBED", message: "You have successfully subscribed to notifications. Type \"nonotifs\" to unsubscribe.") + } else if let body = dictionary["body"] as? Bool, body == false { + AppDelegate.shared?.triggerUnsubscribe() + showAlert(title: "UNSUBSCRIBED", message: "You have successfully unsubscribed from notifications. Type \"notifs\" to subscribe.") + } + case "url": + if let urlString = dictionary["body"] as? String, let url = URL(string: urlString) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + default: + print("Unhandled type: \(type)") + } + } + + func showAlert(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController { + var currentController = rootViewController + while let presentedController = currentController.presentedViewController { + currentController = presentedController + } + currentController.present(alert, animated: true, completion: nil) + } + } +} + +// MARK: - WebView + +struct WebView: UIViewRepresentable { + var url: String + var reloadTrigger: Int + let bootStatus: BootStatus + + func makeCoordinator() -> Coordinator { + Coordinator(bootStatus: bootStatus) + } + + func makeUIView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + let userScript = WKUserScript( + source: "console.log = function() { window.webkit.messageHandlers.iOSAppLog.postMessage([...arguments].join(' ')); }", + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + config.allowsInlineMediaPlayback = true + config.userContentController.addUserScript(userScript) + config.userContentController.add(context.coordinator, name: "iOSAppLog") + config.userContentController.add(context.coordinator, name: "iOSApp") + + // 🧹 Wipe every cache surface that has been observed to keep stale + // /aesthetic.computer/*.mjs (boot, bios, disk, ...) alive between + // launches. Cookies + localStorage are preserved so the user stays + // logged in. + let cacheTypes: Set = [ + WKWebsiteDataTypeDiskCache, + WKWebsiteDataTypeMemoryCache, + WKWebsiteDataTypeFetchCache, + WKWebsiteDataTypeOfflineWebApplicationCache, + WKWebsiteDataTypeServiceWorkerRegistrations, + WKWebsiteDataTypeIndexedDBDatabases, + WKWebsiteDataTypeWebSQLDatabases, + ] + WKWebsiteDataStore.default().removeData( + ofTypes: cacheTypes, + modifiedSince: .distantPast + ) {} + URLCache.shared.removeAllCachedResponses() + + let webView = WKWebView(frame: .zero, configuration: config) + webView.backgroundColor = UIColor(red: grey, green: grey, blue: grey, alpha: 1) + webView.isOpaque = false + webView.uiDelegate = context.coordinator + webView.navigationDelegate = context.coordinator + webView.customUserAgent = "Aesthetic" + + // 👆 Forward touches immediately (default UIScrollView behaviour + // delays the first touch by ~150ms which swallows the tap that + // opens the AC prompt on first launch). + webView.scrollView.delaysContentTouches = false + webView.scrollView.canCancelContentTouches = false + + // Pull-to-refresh: tug the page down to force-reload. Lifesaver when + // a flaky cell connection has stranded the runtime mid-fetch. + let refresh = UIRefreshControl() + refresh.tintColor = .lightGray + refresh.addTarget(context.coordinator, action: #selector(Coordinator.handleRefresh(_:)), for: .valueChanged) + webView.scrollView.refreshControl = refresh + webView.scrollView.bounces = true + webView.scrollView.alwaysBounceVertical = true + + AppDelegate.shared?.appWebView = webView + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + // Gate reloads to actual changes so flipping unrelated SwiftUI state + // (overlay visibility, etc.) doesn't kick a fresh load every time. + let key = "\(url)|\(reloadTrigger)" + if context.coordinator.lastLoadedKey == key { return } + context.coordinator.setLoadedKey(key) + + // 🚫 Belt + braces against stale modules: per-launch ?_iosbust= + // defeats CDN/SW cache keys that ignore cache-control. AC's runtime + // ignores unknown query params on the root. + guard var components = URLComponents(string: url) else { return } + if components.scheme == "http" || components.scheme == "https" { + var items = components.queryItems ?? [] + items.append(URLQueryItem( + name: "_iosbust", + value: String(Int(Date().timeIntervalSince1970)) + )) + components.queryItems = items + } + guard let bustedURL = components.url else { return } + let request = URLRequest( + url: bustedURL, + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, + timeoutInterval: 30 + ) + webView.load(request) + } +} + +// MARK: - Recovery overlay + +struct StalledOverlay: View { + let message: String + let onReload: () -> Void + + var body: some View { + VStack(spacing: 12) { + Spacer() + VStack(spacing: 10) { + Text(message) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + Button(action: onReload) { + Text("Reload") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.black) + .padding(.vertical, 8) + .padding(.horizontal, 22) + .background(Color.yellow) + .cornerRadius(6) + } + } + .padding(.vertical, 14) + .padding(.horizontal, 16) + .background(Color.black.opacity(0.85)) + .cornerRadius(10) + .padding(.horizontal, 24) + .padding(.bottom, 28) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .allowsHitTesting(true) + } +} + +// MARK: - ContentView + +struct ContentView: View { + @StateObject private var monitor = AppNetworkMonitor() + @StateObject private var bootStatus = BootStatus() + @Environment(\.scenePhase) private var scenePhase + @State private var lastBackgroundedAt: Date? = nil + @State private var lastForceLiveTrigger: Int = 0 + + private let liveURL = "https://aesthetic.computer" + private var offlineURL: String { + Bundle.main.url(forResource: "offline", withExtension: "html", subdirectory: "html")?.absoluteString ?? "" + } + + /// The URL we want the WebView to display right now. + private var resolvedURL: String { + // forceLive overrides the monitor briefly — used by the offline page + // "Tap to retry" button when the user knows they're back online but + // the system path monitor hasn't caught up. + if bootStatus.forceLiveTrigger > 0 { return liveURL } + guard let isOnline = monitor.isOnline else { return liveURL } // optimistic before first monitor update + return isOnline ? liveURL : offlineURL + } + + var body: some View { + GeometryReader { geometry in + ZStack { + Color(red: grey, green: grey, blue: grey).ignoresSafeArea() + + if monitor.isOnline != nil || bootStatus.forceLiveTrigger > 0 { + WebView( + url: resolvedURL, + reloadTrigger: bootStatus.reloadTrigger, + bootStatus: bootStatus + ) + } + + if bootStatus.stalled || bootStatus.lastError != nil { + StalledOverlay( + message: bootStatus.lastError ?? "This is taking longer than expected." + ) { + bootStatus.requestReload() + } + } + } + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 24 : 0) + .background(Color(red: grey, green: grey, blue: grey)) + .ignoresSafeArea(.keyboard, edges: .bottom) + .onChange(of: scenePhase) { newPhase in + handleScenePhase(newPhase) + } + .onChange(of: monitor.isOnline) { newValue in + // Re-load when connectivity returns mid-session so the user + // doesn't have to tap reload manually after a flaky cell tower + // hands them back a connection. + if newValue == true { + bootStatus.requestReload() + } + } + .onChange(of: bootStatus.forceLiveTrigger) { newValue in + // forceLive bumped → cause WebView.updateUIView to refire by + // also bumping reloadTrigger (URL alone wouldn't change if we + // were already on liveURL). + if newValue != lastForceLiveTrigger { + lastForceLiveTrigger = newValue + bootStatus.requestReload() + } + } + } + } + + private func handleScenePhase(_ phase: ScenePhase) { + switch phase { + case .background: + lastBackgroundedAt = Date() + case .active: + // After a long sleep the WebView often holds a stale runtime + // (sockets timed out, modules half-loaded). A fresh load is + // cheaper than debugging which subsystem gave up. + if let bg = lastBackgroundedAt { + let elapsed = Date().timeIntervalSince(bg) + if elapsed > 300 { // 5 minutes + bootStatus.requestReload() + } + } + lastBackgroundedAt = nil + default: + break + } + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } +} diff --git a/apple/aesthetic.computer/html/offline.html b/apple/aesthetic.computer/html/offline.html index 2bb02c9167..fa0c259238 100644 --- a/apple/aesthetic.computer/html/offline.html +++ b/apple/aesthetic.computer/html/offline.html @@ -1,59 +1,113 @@ - - - - - - - -
- O - F - F - L - I - N - E -
- - + + + + + + + +
+ O + F + F + L + I + N + E +
+ +
+ The app will reconnect automatically when the network comes back. +
+ + + diff --git a/system/public/aesthetic.computer/boot.mjs b/system/public/aesthetic.computer/boot.mjs index 3f366aab9b..bb836cf6c8 100644 --- a/system/public/aesthetic.computer/boot.mjs +++ b/system/public/aesthetic.computer/boot.mjs @@ -362,6 +362,39 @@ window.acHIDE_BOOT_LOG = hideBootLog; // Expose bootLog globally so bios and disk can update the overlay window.acBOOT_LOG = bootLog; +// 📱 iOS native heartbeat — lets the WKWebView host detect a stalled boot +// (e.g. a half-loaded module that never throws because the underlying +// fetch silently hangs on a flaky cell connection). The Swift host runs a +// watchdog that surfaces a "Reload" overlay if no heartbeat arrives for +// ~25 seconds; we ping every second during boot, then once on `ready`. +(() => { + const ios = window.webkit?.messageHandlers?.iOSApp; + if (!ios) return; + const send = (type) => { + try { + const elapsed = Math.round(performance.now() - bootStartTime); + ios.postMessage(JSON.stringify({ type, elapsed })); + } catch {} + }; + send("boot:heartbeat"); + const beat = setInterval(() => { + if (bootLogHidden) { + send("boot:ready"); + clearInterval(beat); + // After ready, fall back to a slow pulse so a deadlocked piece is + // still recoverable from the host (every 8s; cheap). + setInterval(() => send("boot:heartbeat"), 8000); + return; + } + send("boot:heartbeat"); + }, 1000); + // Safety net: if hideBootLog never fires (catastrophic boot failure), + // stop the boot-phase pings after 90s so we don't keep masking the stall. + setTimeout(() => { + if (!bootLogHidden) clearInterval(beat); + }, 90000); +})(); + // Fetch moods-of-the-day for the boot canvas (fallback in case HTML boot didn't set it) async function fetchBootMoodOfDay() { if (!window.acBootCanvas || window.acBootCanvas.motd) return; -- 2.51.2