Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

apple/ios 1.1(3): hung-load recovery — watchdog + heartbeat + reload paths

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) <noreply@anthropic.com>

+670 -300
+54
apple/PROGRESS.md
··· 5 5 6 6 --- 7 7 8 + ## 1.1 (3) — 2026-04-30 (in progress) 9 + 10 + Reliability pass before this build ships. Field reports of the app sitting 11 + on the boot.mjs animation indefinitely (5,000s+) with no way to recover 12 + short of force-quit. Root cause was structural — there was no host-side 13 + detection of a stuck JS runtime, no failure path on `WKNavigationDelegate`, 14 + and the network monitor cancelled itself on the first satisfied path so 15 + mid-session drops were invisible. 16 + 17 + ### Shipped 18 + - **Long-lived `AppNetworkMonitor`** (replaces the one-shot monitor in 19 + `ContentView`). Stays alive for the whole process and publishes online↔ 20 + offline transitions; flipping back online auto-reloads. 21 + - **Boot watchdog.** `BootStatus` runs a 5s-poll timer that flips 22 + `stalled = true` if no JS heartbeat has arrived for 25 seconds. Stalled 23 + state surfaces a SwiftUI overlay with a "Reload" button anchored at the 24 + bottom — covers the "stuck on boot animation" failure mode. 25 + - **`WKNavigationDelegate`** wired up. Provisional + final navigation 26 + failures populate `BootStatus.lastError` so the same overlay can show a 27 + proper error string ("Cannot reach aesthetic.computer — …") instead of 28 + a frozen UI. 29 + - **JS heartbeat** in `boot.mjs`. Every 1s during boot, then every 8s 30 + after `acHIDE_BOOT_LOG` fires, posts `{type:"boot:heartbeat"|"boot:ready"}` 31 + through the existing `iOSApp` message channel. Lets the host distinguish 32 + "slow load" from "deadlocked". 33 + - **Pull-to-refresh** on the WebView's scroll view (UIRefreshControl 34 + attached in `makeUIView`). Recovery is now one gesture. 35 + - **Scene-phase reload.** Returning the app from >5min of background 36 + triggers a fresh load — the WebView often holds a stale runtime 37 + (timed-out sockets, half-loaded modules) after long sleeps. 38 + - **`offline.html` retry button.** Posts `{type:"reload-online"}` to the 39 + host, which force-loads the live URL even if the path monitor still 40 + reads offline (cell handoffs lag a few seconds). 41 + - **Reload gating in `updateUIView`.** Tracks `lastLoadedKey` on the 42 + Coordinator so reloads only fire on real URL/trigger changes, not on 43 + every SwiftUI re-render (the previous code rebust+reloaded each time). 44 + 45 + ### Wiring summary 46 + - iOS: `apple/aesthetic.computer/ContentView.swift` — 47 + `AppNetworkMonitor`, `BootStatus`, watchdog, nav delegate, overlay, 48 + pull-to-refresh, scene phase. 49 + - iOS: `apple/aesthetic.computer/html/offline.html` — retry button. 50 + - Runtime: `system/public/aesthetic.computer/boot.mjs` — IIFE that posts 51 + `boot:heartbeat`/`boot:ready` via `webkit.messageHandlers.iOSApp`. 52 + 53 + ### Submission notes 54 + - Same bundle IDs / team / Apple ID as 1.1 (2). 55 + - Bump `CFBundleVersion` to `3` before archiving. 56 + - What's New text: "Recovery from hung loads — reload from a stuck boot 57 + screen, automatic retry when the network reconnects, pull down to 58 + refresh." 59 + 60 + --- 61 + 8 62 ## 1.1 (2) — 2026-04-23 9 63 10 64 First update since 1.0 (1) shipped as RC4 in late 2024. Focus: per-device push
+4 -4
apple/aesthetic.computer.xcodeproj/project.pbxproj
··· 426 426 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 427 427 "CODE_SIGN_ENTITLEMENTS[sdk=*]" = aesthetic.computer/aesthetic.computer.entitlements; 428 428 CODE_SIGN_STYLE = Automatic; 429 - CURRENT_PROJECT_VERSION = 2; 429 + CURRENT_PROJECT_VERSION = 3; 430 430 DEVELOPMENT_ASSET_PATHS = "\"aesthetic.computer/Preview Content\""; 431 431 DEVELOPMENT_TEAM = FB5948YR3S; 432 432 ENABLE_PREVIEWS = YES; ··· 461 461 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 462 462 CODE_SIGN_ENTITLEMENTS = "aesthetic.computer/aesthetic.computer-release.entitlements"; 463 463 CODE_SIGN_STYLE = Automatic; 464 - CURRENT_PROJECT_VERSION = 2; 464 + CURRENT_PROJECT_VERSION = 3; 465 465 DEVELOPMENT_ASSET_PATHS = "\"aesthetic.computer/Preview Content\""; 466 466 DEVELOPMENT_TEAM = FB5948YR3S; 467 467 ENABLE_PREVIEWS = YES; ··· 493 493 buildSettings = { 494 494 ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; 495 495 CODE_SIGN_STYLE = Automatic; 496 - CURRENT_PROJECT_VERSION = 2; 496 + CURRENT_PROJECT_VERSION = 3; 497 497 DEVELOPMENT_TEAM = FB5948YR3S; 498 498 GENERATE_INFOPLIST_FILE = YES; 499 499 INFOPLIST_FILE = aesthetic/Info.plist; ··· 520 520 buildSettings = { 521 521 ASSETCATALOG_COMPILER_APPICON_NAME = "iMessage App Icon"; 522 522 CODE_SIGN_STYLE = Automatic; 523 - CURRENT_PROJECT_VERSION = 2; 523 + CURRENT_PROJECT_VERSION = 3; 524 524 DEVELOPMENT_TEAM = FB5948YR3S; 525 525 GENERATE_INFOPLIST_FILE = YES; 526 526 INFOPLIST_FILE = aesthetic/Info.plist;
+466 -237
apple/aesthetic.computer/ContentView.swift
··· 1 - import SwiftUI 2 - import WebKit 3 - import Network 4 - 5 - let grey: CGFloat = 32/255; 6 - 7 - class Coordinator: NSObject, WKScriptMessageHandler, WKUIDelegate { 8 - 9 - //@available(iOS 15.0, *) 10 - func webView(_ webView: WKWebView, 11 - decideMediaCapturePermissionsFor origin: WKSecurityOrigin, 12 - initiatedBy frame: WKFrameInfo, 13 - type: WKMediaCaptureType) async -> WKPermissionDecision { 14 - return .grant; 15 - } 16 - 17 - // Handle JavaScript messages here 18 - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { 19 - if message.name == "iOSAppLog" { 20 - print("JavaScript Log: \(message.body)") 21 - } 22 - else if message.name == "iOSApp", let jsonString = message.body as? String { 23 - 24 - // Convert JSON string to Dictionary 25 - print("JavaScript Log: \(message.body)") 26 - if let data = jsonString.data(using: .utf8) { 27 - do { 28 - if let dictionary = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { 29 - // Now 'dictionary' is a Swift dictionary 30 - print("JSON as dictionary: \(dictionary)") 31 - 32 - // Handle the dictionary as needed 33 - if let type = dictionary["type"] as? String { 34 - switch type { 35 - case "notifications": 36 - if let body = dictionary["body"] as? Bool, body == true { 37 - AppDelegate.shared?.triggerSubscribe() 38 - showAlert(title: "SUBSCRIBED", message: "You have successfully subscribed to notifications. Type \"nonotifs\" to unsubscribe.") 39 - } else if let body = dictionary["body"] as? Bool, body == false { 40 - AppDelegate.shared?.triggerUnsubscribe() 41 - showAlert(title: "UNSUBSCRIBED", message: "You have successfully unsubscribed to notifications. Type \"notifs\" to subscribe.") 42 - } 43 - 44 - case "url": 45 - if let urlString = dictionary["body"] as? String, let url = URL(string: urlString) { 46 - UIApplication.shared.open(url, options: [:], completionHandler: nil) 47 - } 48 - 49 - default: 50 - print("Unhandled type: \(type)") 51 - } 52 - } 53 - } 54 - } catch { 55 - print("Error parsing JSON: \(error)") 56 - } 57 - } 58 - } 59 - } 60 - 61 - func showAlert(title: String, message: String) { 62 - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) 63 - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) 64 - 65 - // Find the active window scene 66 - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, 67 - let rootViewController = windowScene.windows.first?.rootViewController { 68 - 69 - // Present the alert from the top-most view controller 70 - var currentController = rootViewController 71 - while let presentedController = currentController.presentedViewController { 72 - currentController = presentedController 73 - } 74 - currentController.present(alert, animated: true, completion: nil) 75 - } 76 - } 77 - 78 - // func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { 79 - // print("link clicked..."); 80 - // if navigationAction.targetFrame == nil, let url = navigationAction.request.url { 81 - // print("opening...") 82 - // UIApplication.shared.open(url, options: [:], completionHandler: nil) 83 - // decisionHandler(.cancel) 84 - // return 85 - // } 86 - // decisionHandler(.allow) 87 - // } 88 - // 89 - // func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { 90 - // if let url = navigationAction.request.url { 91 - // webView.load(URLRequest(url: url)) 92 - // } 93 - // return nil 94 - // } 95 - 96 - } 97 - 98 - struct WebView: UIViewRepresentable { 99 - var url: String 100 - var isOnline: Bool 101 - 102 - func makeCoordinator() -> Coordinator { 103 - return Coordinator() 104 - } 105 - 106 - func makeUIView(context: Context) -> WKWebView { 107 - let config = WKWebViewConfiguration() 108 - let userScript = WKUserScript(source: "console.log = function() { window.webkit.messageHandlers.iOSAppLog.postMessage([...arguments].join(' ')); }", 109 - injectionTime: .atDocumentStart, 110 - forMainFrameOnly: false) 111 - config.allowsInlineMediaPlayback = true 112 - config.userContentController.addUserScript(userScript) 113 - config.userContentController.add(context.coordinator, name: "iOSAppLog") 114 - config.userContentController.add(context.coordinator, name: "iOSApp") 115 - 116 - // 🧹 Wipe every cache surface that has been observed to keep stale 117 - // /aesthetic.computer/*.mjs (boot, bios, disk, ...) alive between 118 - // launches: HTTP caches, the SW registration + its CacheStorage, 119 - // IndexedDB (where the SW persists its precache manifest version), 120 - // WebSQL/AppCache. We deliberately keep cookies + localStorage so 121 - // the user stays logged in. The wipe used to be fire-and-forget, 122 - // which raced webView.load() and frequently lost — the load would 123 - // start before removal finished and the SW would re-hydrate from 124 - // its old caches. We now block until removal completes (semaphore 125 - // off the main thread, then post the load) so the first request 126 - // truly hits an empty data store. 127 - let cacheTypes: Set<String> = [ 128 - WKWebsiteDataTypeDiskCache, 129 - WKWebsiteDataTypeMemoryCache, 130 - WKWebsiteDataTypeFetchCache, 131 - WKWebsiteDataTypeOfflineWebApplicationCache, 132 - WKWebsiteDataTypeServiceWorkerRegistrations, 133 - WKWebsiteDataTypeIndexedDBDatabases, 134 - WKWebsiteDataTypeWebSQLDatabases, 135 - ] 136 - WKWebsiteDataStore.default().removeData( 137 - ofTypes: cacheTypes, 138 - modifiedSince: .distantPast 139 - ) {} 140 - // Also nuke the foundation-level URL cache that backs WKWebView's 141 - // subresource fetches; this is independent of WKWebsiteDataStore. 142 - URLCache.shared.removeAllCachedResponses() 143 - 144 - let webView = WKWebView(frame: .zero, configuration: config) 145 - webView.backgroundColor = UIColor(red: grey, green: grey, blue: grey, alpha: 1) 146 - webView.isOpaque = false 147 - // webView.navigationDelegate = context.coordinator 148 - webView.uiDelegate = context.coordinator 149 - webView.customUserAgent = "Aesthetic" 150 - 151 - // 👆 Fix "first tap dropped" on iOS: UIScrollView (which WKWebView 152 - // hosts its content in) defaults to delaysContentTouches = true 153 - // and waits ~150ms before forwarding the first touch to the page 154 - // so it can decide whether the gesture is a scroll. On the AC 155 - // canvas, that delay swallows the tap that opens the prompt — the 156 - // very first interaction after launch silently no-ops. Disabling 157 - // both delaysContentTouches and canCancelContentTouches forwards 158 - // touches to JS immediately, which matches Safari's behaviour for 159 - // pages that handle their own gestures. 160 - webView.scrollView.delaysContentTouches = false 161 - webView.scrollView.canCancelContentTouches = false 162 - 163 - // Add a script message handler to handle messages from JavaScript 164 - AppDelegate.shared?.appWebView = webView // Set the shared appWebView 165 - return webView 166 - } 167 - 168 - func updateUIView(_ webView: WKWebView, context: Context) { 169 - // let testHTML = "<html><script>window.ontouchstart = () => { console.log('hi'); const a = document.createElement('a'); a.href = 'https://example.com'; a.innerText = 'OKAY'; document.body.appendChild(a); a.click(); }</script><body></body></html>" 170 - // webView.loadHTMLString(testHTML, baseURL: nil) 171 - // 🚫 Belt + braces against stale modules: 172 - // • .reloadIgnoringLocalCacheData bypasses the URL cache. 173 - // • A per-launch ?_iosbust=<timestamp> query param defeats any 174 - // cache key (CDN, SW match) that ignores cache-control. The 175 - // AC site itself ignores unknown query params on the root. 176 - guard var components = URLComponents(string: url) else { return } 177 - if components.scheme == "http" || components.scheme == "https" { 178 - var items = components.queryItems ?? [] 179 - items.append(URLQueryItem( 180 - name: "_iosbust", 181 - value: String(Int(Date().timeIntervalSince1970)) 182 - )) 183 - components.queryItems = items 184 - } 185 - guard let bustedURL = components.url else { return } 186 - let request = URLRequest( 187 - url: bustedURL, 188 - cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, 189 - timeoutInterval: 30 190 - ) 191 - webView.load(request) 192 - } 193 - 194 - } 195 - 196 - struct ContentView: View { 197 - @State private var isOnline: Bool? = nil 198 - 199 - var body: some View { 200 - GeometryReader { geometry in 201 - VStack { 202 - if let isOnline = isOnline { 203 - if isOnline { 204 - WebView(url: "https://aesthetic.computer", isOnline: true) 205 - } else { 206 - let test = (Bundle.main.url(forResource: "offline", withExtension: "html", subdirectory: "html")?.absoluteString ?? "") 207 - WebView(url: test, isOnline: false) 208 - 209 - } 210 - } else { 211 - Color(red: grey, green: grey, blue: grey) 212 - .frame(maxWidth: .infinity, maxHeight: .infinity) 213 - } 214 - } 215 - .onAppear { 216 - let monitor = NWPathMonitor() 217 - monitor.pathUpdateHandler = { path in 218 - self.isOnline = path.status == .satisfied 219 - if self.isOnline == true { 220 - monitor.cancel() //stops monitoring once you are online 221 - } 222 - } 223 - let queue = DispatchQueue(label: "NetworkMonitor") 224 - monitor.start(queue: queue) 225 - } 226 - .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 24 : 0) 227 - .background(Color(red: grey, green: grey, blue: grey)) 228 - .ignoresSafeArea(.keyboard, edges: .bottom) 229 - } 230 - } 231 - } 232 - 233 - struct ContentView_Previews: PreviewProvider { 234 - static var previews: some View { 235 - ContentView() 236 - } 237 - } 1 + import SwiftUI 2 + import WebKit 3 + import Network 4 + import Combine 5 + 6 + let grey: CGFloat = 32/255 7 + 8 + // MARK: - Long-lived network monitor 9 + // 10 + // The previous implementation cancelled NWPathMonitor on the first .satisfied 11 + // path, which meant a mid-session connectivity drop was invisible to the app. 12 + // This monitor stays alive for the whole process so the UI can react to 13 + // every transition (online → offline → online). 14 + 15 + final class AppNetworkMonitor: ObservableObject { 16 + @Published var isOnline: Bool? = nil 17 + private let monitor = NWPathMonitor() 18 + private let queue = DispatchQueue(label: "AppNetworkMonitor") 19 + 20 + init() { 21 + monitor.pathUpdateHandler = { [weak self] path in 22 + let online = path.status == .satisfied 23 + DispatchQueue.main.async { 24 + self?.isOnline = online 25 + } 26 + } 27 + monitor.start(queue: queue) 28 + } 29 + 30 + deinit { monitor.cancel() } 31 + } 32 + 33 + // MARK: - Boot status / watchdog 34 + // 35 + // Tracks whether the WebView's JS runtime is making forward progress. The 36 + // runtime sends a heartbeat (window.iOSReportBootHeartbeat) at boot and 37 + // periodically afterwards via webkit.messageHandlers.iOSApp. If we don't 38 + // hear from it for STALL_TIMEOUT seconds while a navigation is in flight, 39 + // we surface a "Reload" overlay so the user can recover without force-quitting. 40 + // 41 + // Bumping `reloadTrigger` causes WebView.updateUIView to issue a fresh load 42 + // (with a per-launch _iosbust query param). `forceLiveTrigger` asks the 43 + // view to attempt the live URL even if the network monitor is offline — 44 + // useful for the "Tap to retry" button on offline.html when the monitor 45 + // hasn't flipped yet. 46 + 47 + final class BootStatus: ObservableObject { 48 + @Published var stalled: Bool = false 49 + @Published var lastError: String? = nil 50 + @Published var reloadTrigger: Int = 0 51 + @Published var forceLiveTrigger: Int = 0 52 + 53 + private var lastHeartbeatAt = Date() 54 + private var watchdog: Timer? = nil 55 + private var watchdogActive = false 56 + private(set) var isReady = false 57 + 58 + private let stallTimeout: TimeInterval = 25 59 + private let pollInterval: TimeInterval = 5 60 + 61 + func startWatchdog() { 62 + stopWatchdog() 63 + lastHeartbeatAt = Date() 64 + isReady = false 65 + watchdogActive = true 66 + DispatchQueue.main.async { 67 + self.stalled = false 68 + self.lastError = nil 69 + } 70 + let timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in 71 + guard let self = self, self.watchdogActive else { return } 72 + let elapsed = Date().timeIntervalSince(self.lastHeartbeatAt) 73 + if elapsed > self.stallTimeout { 74 + DispatchQueue.main.async { 75 + if !self.stalled { self.stalled = true } 76 + } 77 + } 78 + } 79 + RunLoop.main.add(timer, forMode: .common) 80 + watchdog = timer 81 + } 82 + 83 + func stopWatchdog() { 84 + watchdogActive = false 85 + watchdog?.invalidate() 86 + watchdog = nil 87 + } 88 + 89 + func heartbeat(ready: Bool = false) { 90 + lastHeartbeatAt = Date() 91 + if ready { 92 + isReady = true 93 + stopWatchdog() 94 + } 95 + DispatchQueue.main.async { 96 + if self.stalled { self.stalled = false } 97 + } 98 + } 99 + 100 + func setError(_ msg: String) { 101 + DispatchQueue.main.async { 102 + self.lastError = msg 103 + } 104 + } 105 + 106 + func requestReload() { 107 + stopWatchdog() 108 + DispatchQueue.main.async { 109 + self.stalled = false 110 + self.lastError = nil 111 + self.reloadTrigger += 1 112 + } 113 + } 114 + 115 + func requestForceLive() { 116 + stopWatchdog() 117 + DispatchQueue.main.async { 118 + self.stalled = false 119 + self.lastError = nil 120 + self.forceLiveTrigger += 1 121 + } 122 + } 123 + } 124 + 125 + // MARK: - Coordinator 126 + // 127 + // Owns: WKScriptMessageHandler (JS bridge), WKNavigationDelegate (load 128 + // success/failure), WKUIDelegate (media permissions). Holds a weak ref to 129 + // BootStatus so heartbeats / errors flow upward. 130 + 131 + class Coordinator: NSObject, WKScriptMessageHandler, WKUIDelegate, WKNavigationDelegate { 132 + weak var bootStatus: BootStatus? 133 + private(set) var lastLoadedKey: String? = nil 134 + 135 + init(bootStatus: BootStatus) { 136 + self.bootStatus = bootStatus 137 + super.init() 138 + } 139 + 140 + func setLoadedKey(_ key: String) { lastLoadedKey = key } 141 + 142 + // MARK: media permissions 143 + func webView(_ webView: WKWebView, 144 + decideMediaCapturePermissionsFor origin: WKSecurityOrigin, 145 + initiatedBy frame: WKFrameInfo, 146 + type: WKMediaCaptureType) async -> WKPermissionDecision { 147 + return .grant 148 + } 149 + 150 + // MARK: navigation 151 + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { 152 + // Only watchdog real network loads; offline.html doesn't have JS that 153 + // can heartbeat back, so we'd false-alarm on the static offline page. 154 + if let url = webView.url, url.scheme == "https" || url.scheme == "http" { 155 + bootStatus?.startWatchdog() 156 + } 157 + } 158 + 159 + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { 160 + bootStatus?.setError("Load failed — \(error.localizedDescription)") 161 + } 162 + 163 + func webView(_ webView: WKWebView, 164 + didFailProvisionalNavigation navigation: WKNavigation!, 165 + withError error: Error) { 166 + bootStatus?.setError("Cannot reach aesthetic.computer\n\(error.localizedDescription)") 167 + } 168 + 169 + // MARK: pull-to-refresh 170 + @objc func handleRefresh(_ sender: UIRefreshControl) { 171 + bootStatus?.requestReload() 172 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { 173 + sender.endRefreshing() 174 + } 175 + } 176 + 177 + // MARK: JS → native messages 178 + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { 179 + if message.name == "iOSAppLog" { 180 + print("JavaScript Log: \(message.body)") 181 + return 182 + } 183 + 184 + guard message.name == "iOSApp", let jsonString = message.body as? String else { return } 185 + guard let data = jsonString.data(using: .utf8) else { return } 186 + guard let dictionary = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { 187 + print("📱 iOSApp message: failed to parse \(jsonString)") 188 + return 189 + } 190 + 191 + guard let type = dictionary["type"] as? String else { return } 192 + 193 + switch type { 194 + case "boot:heartbeat": 195 + bootStatus?.heartbeat() 196 + case "boot:ready": 197 + bootStatus?.heartbeat(ready: true) 198 + case "reload": 199 + bootStatus?.requestReload() 200 + case "reload-online": 201 + // Force-load the live URL even if the network monitor still says 202 + // offline (used by the offline.html "Tap to retry" button — the 203 + // monitor can lag behind a real reconnect by a few seconds). 204 + bootStatus?.requestForceLive() 205 + case "notifications": 206 + if let body = dictionary["body"] as? Bool, body == true { 207 + AppDelegate.shared?.triggerSubscribe() 208 + showAlert(title: "SUBSCRIBED", message: "You have successfully subscribed to notifications. Type \"nonotifs\" to unsubscribe.") 209 + } else if let body = dictionary["body"] as? Bool, body == false { 210 + AppDelegate.shared?.triggerUnsubscribe() 211 + showAlert(title: "UNSUBSCRIBED", message: "You have successfully unsubscribed from notifications. Type \"notifs\" to subscribe.") 212 + } 213 + case "url": 214 + if let urlString = dictionary["body"] as? String, let url = URL(string: urlString) { 215 + UIApplication.shared.open(url, options: [:], completionHandler: nil) 216 + } 217 + default: 218 + print("Unhandled type: \(type)") 219 + } 220 + } 221 + 222 + func showAlert(title: String, message: String) { 223 + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) 224 + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) 225 + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, 226 + let rootViewController = windowScene.windows.first?.rootViewController { 227 + var currentController = rootViewController 228 + while let presentedController = currentController.presentedViewController { 229 + currentController = presentedController 230 + } 231 + currentController.present(alert, animated: true, completion: nil) 232 + } 233 + } 234 + } 235 + 236 + // MARK: - WebView 237 + 238 + struct WebView: UIViewRepresentable { 239 + var url: String 240 + var reloadTrigger: Int 241 + let bootStatus: BootStatus 242 + 243 + func makeCoordinator() -> Coordinator { 244 + Coordinator(bootStatus: bootStatus) 245 + } 246 + 247 + func makeUIView(context: Context) -> WKWebView { 248 + let config = WKWebViewConfiguration() 249 + let userScript = WKUserScript( 250 + source: "console.log = function() { window.webkit.messageHandlers.iOSAppLog.postMessage([...arguments].join(' ')); }", 251 + injectionTime: .atDocumentStart, 252 + forMainFrameOnly: false 253 + ) 254 + config.allowsInlineMediaPlayback = true 255 + config.userContentController.addUserScript(userScript) 256 + config.userContentController.add(context.coordinator, name: "iOSAppLog") 257 + config.userContentController.add(context.coordinator, name: "iOSApp") 258 + 259 + // 🧹 Wipe every cache surface that has been observed to keep stale 260 + // /aesthetic.computer/*.mjs (boot, bios, disk, ...) alive between 261 + // launches. Cookies + localStorage are preserved so the user stays 262 + // logged in. 263 + let cacheTypes: Set<String> = [ 264 + WKWebsiteDataTypeDiskCache, 265 + WKWebsiteDataTypeMemoryCache, 266 + WKWebsiteDataTypeFetchCache, 267 + WKWebsiteDataTypeOfflineWebApplicationCache, 268 + WKWebsiteDataTypeServiceWorkerRegistrations, 269 + WKWebsiteDataTypeIndexedDBDatabases, 270 + WKWebsiteDataTypeWebSQLDatabases, 271 + ] 272 + WKWebsiteDataStore.default().removeData( 273 + ofTypes: cacheTypes, 274 + modifiedSince: .distantPast 275 + ) {} 276 + URLCache.shared.removeAllCachedResponses() 277 + 278 + let webView = WKWebView(frame: .zero, configuration: config) 279 + webView.backgroundColor = UIColor(red: grey, green: grey, blue: grey, alpha: 1) 280 + webView.isOpaque = false 281 + webView.uiDelegate = context.coordinator 282 + webView.navigationDelegate = context.coordinator 283 + webView.customUserAgent = "Aesthetic" 284 + 285 + // 👆 Forward touches immediately (default UIScrollView behaviour 286 + // delays the first touch by ~150ms which swallows the tap that 287 + // opens the AC prompt on first launch). 288 + webView.scrollView.delaysContentTouches = false 289 + webView.scrollView.canCancelContentTouches = false 290 + 291 + // Pull-to-refresh: tug the page down to force-reload. Lifesaver when 292 + // a flaky cell connection has stranded the runtime mid-fetch. 293 + let refresh = UIRefreshControl() 294 + refresh.tintColor = .lightGray 295 + refresh.addTarget(context.coordinator, action: #selector(Coordinator.handleRefresh(_:)), for: .valueChanged) 296 + webView.scrollView.refreshControl = refresh 297 + webView.scrollView.bounces = true 298 + webView.scrollView.alwaysBounceVertical = true 299 + 300 + AppDelegate.shared?.appWebView = webView 301 + return webView 302 + } 303 + 304 + func updateUIView(_ webView: WKWebView, context: Context) { 305 + // Gate reloads to actual changes so flipping unrelated SwiftUI state 306 + // (overlay visibility, etc.) doesn't kick a fresh load every time. 307 + let key = "\(url)|\(reloadTrigger)" 308 + if context.coordinator.lastLoadedKey == key { return } 309 + context.coordinator.setLoadedKey(key) 310 + 311 + // 🚫 Belt + braces against stale modules: per-launch ?_iosbust=<ts> 312 + // defeats CDN/SW cache keys that ignore cache-control. AC's runtime 313 + // ignores unknown query params on the root. 314 + guard var components = URLComponents(string: url) else { return } 315 + if components.scheme == "http" || components.scheme == "https" { 316 + var items = components.queryItems ?? [] 317 + items.append(URLQueryItem( 318 + name: "_iosbust", 319 + value: String(Int(Date().timeIntervalSince1970)) 320 + )) 321 + components.queryItems = items 322 + } 323 + guard let bustedURL = components.url else { return } 324 + let request = URLRequest( 325 + url: bustedURL, 326 + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, 327 + timeoutInterval: 30 328 + ) 329 + webView.load(request) 330 + } 331 + } 332 + 333 + // MARK: - Recovery overlay 334 + 335 + struct StalledOverlay: View { 336 + let message: String 337 + let onReload: () -> Void 338 + 339 + var body: some View { 340 + VStack(spacing: 12) { 341 + Spacer() 342 + VStack(spacing: 10) { 343 + Text(message) 344 + .font(.system(size: 14, weight: .medium)) 345 + .foregroundColor(.white) 346 + .multilineTextAlignment(.center) 347 + .padding(.horizontal, 12) 348 + Button(action: onReload) { 349 + Text("Reload") 350 + .font(.system(size: 15, weight: .semibold)) 351 + .foregroundColor(.black) 352 + .padding(.vertical, 8) 353 + .padding(.horizontal, 22) 354 + .background(Color.yellow) 355 + .cornerRadius(6) 356 + } 357 + } 358 + .padding(.vertical, 14) 359 + .padding(.horizontal, 16) 360 + .background(Color.black.opacity(0.85)) 361 + .cornerRadius(10) 362 + .padding(.horizontal, 24) 363 + .padding(.bottom, 28) 364 + } 365 + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) 366 + .allowsHitTesting(true) 367 + } 368 + } 369 + 370 + // MARK: - ContentView 371 + 372 + struct ContentView: View { 373 + @StateObject private var monitor = AppNetworkMonitor() 374 + @StateObject private var bootStatus = BootStatus() 375 + @Environment(\.scenePhase) private var scenePhase 376 + @State private var lastBackgroundedAt: Date? = nil 377 + @State private var lastForceLiveTrigger: Int = 0 378 + 379 + private let liveURL = "https://aesthetic.computer" 380 + private var offlineURL: String { 381 + Bundle.main.url(forResource: "offline", withExtension: "html", subdirectory: "html")?.absoluteString ?? "" 382 + } 383 + 384 + /// The URL we want the WebView to display right now. 385 + private var resolvedURL: String { 386 + // forceLive overrides the monitor briefly — used by the offline page 387 + // "Tap to retry" button when the user knows they're back online but 388 + // the system path monitor hasn't caught up. 389 + if bootStatus.forceLiveTrigger > 0 { return liveURL } 390 + guard let isOnline = monitor.isOnline else { return liveURL } // optimistic before first monitor update 391 + return isOnline ? liveURL : offlineURL 392 + } 393 + 394 + var body: some View { 395 + GeometryReader { geometry in 396 + ZStack { 397 + Color(red: grey, green: grey, blue: grey).ignoresSafeArea() 398 + 399 + if monitor.isOnline != nil || bootStatus.forceLiveTrigger > 0 { 400 + WebView( 401 + url: resolvedURL, 402 + reloadTrigger: bootStatus.reloadTrigger, 403 + bootStatus: bootStatus 404 + ) 405 + } 406 + 407 + if bootStatus.stalled || bootStatus.lastError != nil { 408 + StalledOverlay( 409 + message: bootStatus.lastError ?? "This is taking longer than expected." 410 + ) { 411 + bootStatus.requestReload() 412 + } 413 + } 414 + } 415 + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 24 : 0) 416 + .background(Color(red: grey, green: grey, blue: grey)) 417 + .ignoresSafeArea(.keyboard, edges: .bottom) 418 + .onChange(of: scenePhase) { newPhase in 419 + handleScenePhase(newPhase) 420 + } 421 + .onChange(of: monitor.isOnline) { newValue in 422 + // Re-load when connectivity returns mid-session so the user 423 + // doesn't have to tap reload manually after a flaky cell tower 424 + // hands them back a connection. 425 + if newValue == true { 426 + bootStatus.requestReload() 427 + } 428 + } 429 + .onChange(of: bootStatus.forceLiveTrigger) { newValue in 430 + // forceLive bumped → cause WebView.updateUIView to refire by 431 + // also bumping reloadTrigger (URL alone wouldn't change if we 432 + // were already on liveURL). 433 + if newValue != lastForceLiveTrigger { 434 + lastForceLiveTrigger = newValue 435 + bootStatus.requestReload() 436 + } 437 + } 438 + } 439 + } 440 + 441 + private func handleScenePhase(_ phase: ScenePhase) { 442 + switch phase { 443 + case .background: 444 + lastBackgroundedAt = Date() 445 + case .active: 446 + // After a long sleep the WebView often holds a stale runtime 447 + // (sockets timed out, modules half-loaded). A fresh load is 448 + // cheaper than debugging which subsystem gave up. 449 + if let bg = lastBackgroundedAt { 450 + let elapsed = Date().timeIntervalSince(bg) 451 + if elapsed > 300 { // 5 minutes 452 + bootStatus.requestReload() 453 + } 454 + } 455 + lastBackgroundedAt = nil 456 + default: 457 + break 458 + } 459 + } 460 + } 461 + 462 + struct ContentView_Previews: PreviewProvider { 463 + static var previews: some View { 464 + ContentView() 465 + } 466 + }
+113 -59
apple/aesthetic.computer/html/offline.html
··· 1 - <!DOCTYPE html> 2 - <html> 3 - <head> 4 - <meta 5 - name="viewport" 6 - content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" 7 - /> 8 - <style> 9 - @font-face { 10 - font-family: 'ProggyClean'; 11 - src: url('ProggyClean.ttf') format('truetype'); 12 - font-weight: normal; 13 - font-style: normal; 14 - } 15 - 16 - html { 17 - background-color: rgb(32, 32, 32); 18 - /* background-color: yellow; */ 19 - margin: 0; 20 - padding: 8px; 21 - height: calc(100% - 16px); 22 - } 23 - 24 - body { 25 - font-family: ProggyClean, monospace; 26 - background-color: rgb(60,55,70); 27 - overflow: hidden; 28 - -webkit-text-size-adjust: none; 29 - -webkit-user-select: none; 30 - user-select: none; 31 - display: flex; 32 - width: 100%; 33 - height: 100%; 34 - margin: 0; 35 - } 36 - div { 37 - margin: auto; 38 - color: yellow; 39 - background-color: red; 40 - padding: 2vw 2vw 1vw 2vw; /* top, right, bottom, left */ 41 - font-size: 0; 42 - } 43 - div span { 44 - font-size: 15vw; 45 - } 46 - </style> 47 - </head> 48 - <body> 49 - <div> 50 - <span>O</span> 51 - <span>F</span> 52 - <span>F</span> 53 - <span>L</span> 54 - <span>I</span> 55 - <span>N</span> 56 - <span>E</span> 57 - </div> 58 - </body> 59 - </html> 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta 5 + name="viewport" 6 + content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" 7 + /> 8 + <style> 9 + @font-face { 10 + font-family: 'ProggyClean'; 11 + src: url('ProggyClean.ttf') format('truetype'); 12 + font-weight: normal; 13 + font-style: normal; 14 + } 15 + 16 + html { 17 + background-color: rgb(32, 32, 32); 18 + margin: 0; 19 + padding: 8px; 20 + height: calc(100% - 16px); 21 + } 22 + 23 + body { 24 + font-family: ProggyClean, monospace; 25 + background-color: rgb(60, 55, 70); 26 + overflow: hidden; 27 + -webkit-text-size-adjust: none; 28 + -webkit-user-select: none; 29 + user-select: none; 30 + display: flex; 31 + flex-direction: column; 32 + align-items: center; 33 + justify-content: center; 34 + gap: 4vw; 35 + width: 100%; 36 + height: 100%; 37 + margin: 0; 38 + } 39 + 40 + .word { 41 + color: yellow; 42 + background-color: red; 43 + padding: 2vw 2vw 1vw 2vw; 44 + font-size: 0; 45 + } 46 + .word span { 47 + font-size: 15vw; 48 + } 49 + 50 + button { 51 + font-family: ProggyClean, monospace; 52 + font-size: 6vw; 53 + color: black; 54 + background-color: yellow; 55 + border: 0; 56 + padding: 2vw 5vw 1.4vw 5vw; 57 + cursor: pointer; 58 + -webkit-tap-highlight-color: transparent; 59 + touch-action: manipulation; 60 + } 61 + button:active { 62 + background-color: white; 63 + } 64 + 65 + .hint { 66 + color: rgba(255, 255, 255, 0.55); 67 + font-size: 4vw; 68 + text-align: center; 69 + max-width: 80%; 70 + line-height: 1.3; 71 + } 72 + </style> 73 + </head> 74 + <body> 75 + <div class="word"> 76 + <span>O</span> 77 + <span>F</span> 78 + <span>F</span> 79 + <span>L</span> 80 + <span>I</span> 81 + <span>N</span> 82 + <span>E</span> 83 + </div> 84 + <button id="retry" type="button">Tap to retry</button> 85 + <div class="hint"> 86 + The app will reconnect automatically when the network comes back. 87 + </div> 88 + <script> 89 + // Tell the native host to attempt the live URL even if its path 90 + // monitor still reads offline (the monitor can lag a few seconds 91 + // behind a real reconnect, especially on cell handoffs). 92 + const btn = document.getElementById("retry"); 93 + btn.addEventListener("click", () => { 94 + btn.disabled = true; 95 + btn.textContent = "Retrying…"; 96 + try { 97 + window.webkit?.messageHandlers?.iOSApp?.postMessage( 98 + JSON.stringify({ type: "reload-online" }) 99 + ); 100 + } catch (err) { 101 + // No native bridge (running outside the app) — just reload. 102 + window.location.reload(); 103 + } 104 + // Re-enable after a moment so a second tap is possible if the 105 + // first retry didn't actually swap the view. 106 + setTimeout(() => { 107 + btn.disabled = false; 108 + btn.textContent = "Tap to retry"; 109 + }, 4000); 110 + }); 111 + </script> 112 + </body> 113 + </html>
+33
system/public/aesthetic.computer/boot.mjs
··· 362 362 // Expose bootLog globally so bios and disk can update the overlay 363 363 window.acBOOT_LOG = bootLog; 364 364 365 + // 📱 iOS native heartbeat — lets the WKWebView host detect a stalled boot 366 + // (e.g. a half-loaded module that never throws because the underlying 367 + // fetch silently hangs on a flaky cell connection). The Swift host runs a 368 + // watchdog that surfaces a "Reload" overlay if no heartbeat arrives for 369 + // ~25 seconds; we ping every second during boot, then once on `ready`. 370 + (() => { 371 + const ios = window.webkit?.messageHandlers?.iOSApp; 372 + if (!ios) return; 373 + const send = (type) => { 374 + try { 375 + const elapsed = Math.round(performance.now() - bootStartTime); 376 + ios.postMessage(JSON.stringify({ type, elapsed })); 377 + } catch {} 378 + }; 379 + send("boot:heartbeat"); 380 + const beat = setInterval(() => { 381 + if (bootLogHidden) { 382 + send("boot:ready"); 383 + clearInterval(beat); 384 + // After ready, fall back to a slow pulse so a deadlocked piece is 385 + // still recoverable from the host (every 8s; cheap). 386 + setInterval(() => send("boot:heartbeat"), 8000); 387 + return; 388 + } 389 + send("boot:heartbeat"); 390 + }, 1000); 391 + // Safety net: if hideBootLog never fires (catastrophic boot failure), 392 + // stop the boot-phase pings after 90s so we don't keep masking the stall. 393 + setTimeout(() => { 394 + if (!bootLogHidden) clearInterval(beat); 395 + }, 90000); 396 + })(); 397 + 365 398 // Fetch moods-of-the-day for the boot canvas (fallback in case HTML boot didn't set it) 366 399 async function fetchBootMoodOfDay() { 367 400 if (!window.acBootCanvas || window.acBootCanvas.motd) return;