ios: reload cached app webviews when an app updates or is removed (#1393)
* ios: reload cached app webviews when an app updates or is removed An updated member (or custom) app kept running its old code in every live native pane: the apps_changed frame only refreshed the catalogue metadata and reloaded the hidden CommandHost page, while the visible and parked app webviews in FeedWebCache / AppWebViewCache stayed untouched — and the page inside them can't swap code itself (sandbox.js refuses to re-register a loaded app id; custom apps are ES modules imported at boot). The web shell's embed branch already hands reload responsibility to native; this is that missing half. Now an apps_changed frame with kind updated/removed also reloads every cached app webview of the instance, on both platforms. The embed seed is a WKUserScript on the configuration, so it survives the reload and each page reboots straight back into its pinned surface running the new version. A created app still registers live in-page, so it causes no reload flash. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: visible app panes get an inline update banner instead of a live reload Never reload a surface someone is actively looking at: parked pages still reload invisibly, but a page that's on screen when the update arrives now gets flagged on its bridge, and the pane grows an inline bar — 'App updated. Reload / Later' — above the webview (in layout, never floating). Reload swaps now; Later hides the bar but keeps the flag, so the cache reloads the page the next time it's attached from parked — you're never yanked, and you always come back to fresh code. The bar rides the bridges the panes already observe (WebBridge / MacWebBridge), so all three iOS surfaces (app thread, group pane, space app — the space pane grew the bridge wrapper it lacked) and the Mac's one AppWebSurface get it from the same state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * tools: dev run mac resolves the app path with the derivedDataPath it builds with The -showBuildSettings lookup omitted the -derivedDataPath 'build mac' passes, so it reported the default DerivedData products dir — where the app was never built — and the launch failed with 'does not exist'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
10 files changed,
+259
−35
ios/LChat/FeedWebCache.swift+38 −3ios/LChat/Views/AppThreadScreen.swift+8 −5ios/LChat/Views/AppUpdateBar.swift+30 −0ios/LChat/Views/GroupAppPane.swift+8 −5ios/LChat/Views/SpaceAppScreen.swift+36 −2ios/LChat/Views/ThreadListView.swift+11 −1ios/LChat/Views/WebBridge.swift+17 −0ios/LChatMac/AppThreadWebView.swift+86 −5ios/LChatMac/NativeChatView.swift+11 −1tools/dev+14 −13
diff --git a/ios/LChat/FeedWebCache.swift b/ios/LChat/FeedWebCache.swift| index c0e4023..6a6ec54 100644 |
| --- a/ios/LChat/FeedWebCache.swift |
| +++ b/ios/LChat/FeedWebCache.swift |
| @@ -62,6 +62,28 @@ final class FeedWebCache { |
| } |
| } |
| + // An app's code changed under a live page (apps_changed, kind updated / |
| + // removed). A reload is the only way to swap — custom apps are ES modules |
| + // imported at boot, and sandbox.js refuses to re-register a member app id |
| + // a running page already loaded — but never reload a surface someone is |
| + // actively looking at: parked pages reload invisibly now, while a page |
| + // that's on screen gets flagged and its pane offers an inline reload |
| + // (AppUpdateBar). The embed seed survives the reload (it's a WKUserScript |
| + // on the configuration), so a page reboots straight back into its pinned |
| + // surface. |
| + func handleAppsChanged(instanceID: String, kind: String) { |
| + for (key, entry) in entries where key.hasPrefix(instanceID + "|") { |
| + if entry.web.window != nil { |
| + entry.bridge.appUpdateKind = kind |
| + entry.bridge.appUpdateDismissed = false |
| + } else { |
| + entry.bridge.appUpdateKind = nil |
| + entry.bridge.appUpdateDismissed = false |
| + entry.web.reload() |
| + } |
| + } |
| + } |
| + |
| // The live webview for this thread — reused if we have it, created (and left |
| // loading) if not. Marks the entry most-recently-used so eviction spares it. |
| // `app` names one of a GROUP's added apps: that pane renders the app's |
| @@ -79,9 +101,10 @@ final class FeedWebCache { |
| // The bridge that owns this thread's webview. Panes observe this instead of |
| // creating their own, so ready state survives with the page. |
| func bridge(for instance: SavedInstance, thread: ChatThread, myUserId: String, |
| - instanceKeyB64: String?, app: String? = nil) -> WebBridge { |
| + instanceKeyB64: String?, app: String? = nil, |
| + space: Bool = false) -> WebBridge { |
| entry(for: instance, thread: thread, myUserId: myUserId, |
| - instanceKeyB64: instanceKeyB64, app: app).bridge |
| + instanceKeyB64: instanceKeyB64, app: app, space: space).bridge |
| } |
| private func entry(for instance: SavedInstance, thread: ChatThread, myUserId: String, |
| @@ -96,7 +119,19 @@ final class FeedWebCache { |
| + (space ? "|space:" + (app ?? "") : (app != nil ? "|groupapps" : "")) |
| // Reuse only if the page was seeded with the same thread key — a re-invite / |
| // key change makes the parked page stale, so rebuild it. |
| - if let e = entries[key], e.seedKey == thread.threadKeyB64 { return e } |
| + if let e = entries[key], e.seedKey == thread.threadKeyB64 { |
| + // A deferred app update (flagged while on screen, or "Later"): |
| + // this access is (re)attaching a parked page — reload it now, |
| + // before it's back under someone's eyes. A page that's already |
| + // on screen (window != nil, a SwiftUI re-render) keeps running |
| + // and keeps its banner. |
| + if e.bridge.appUpdateKind != nil, e.web.window == nil { |
| + e.bridge.appUpdateKind = nil |
| + e.bridge.appUpdateDismissed = false |
| + e.web.reload() |
| + } |
| + return e |
| + } |
| if let e = entries.removeValue(forKey: key) { release(e) } |
| let e = makeEntry(instance: instance, thread: thread, myUserId: myUserId, |
| instanceKeyB64: instanceKeyB64, app: app, space: space) |
diff --git a/ios/LChat/Views/AppThreadScreen.swift b/ios/LChat/Views/AppThreadScreen.swift| index 65ecbe6..790cb9a 100644 |
| --- a/ios/LChat/Views/AppThreadScreen.swift |
| +++ b/ios/LChat/Views/AppThreadScreen.swift |
| @@ -171,11 +171,14 @@ struct AppInterfacePane: View { |
| } |
| var body: some View { |
| - ZStack { |
| - AppInterfaceWebView(instance: instance, thread: thread, |
| - myUserId: myUserId, instanceKeyB64: instanceKeyB64) |
| - if !bridge.pageReady { |
| - WebLoadingVeil().transition(.opacity) |
| + VStack(spacing: 0) { |
| + AppUpdateBar(bridge: bridge) |
| + ZStack { |
| + AppInterfaceWebView(instance: instance, thread: thread, |
| + myUserId: myUserId, instanceKeyB64: instanceKeyB64) |
| + if !bridge.pageReady { |
| + WebLoadingVeil().transition(.opacity) |
| + } |
| } |
| } |
| .animation(.easeOut(duration: 0.25), value: bridge.pageReady) |
diff --git a/ios/LChat/Views/AppUpdateBar.swift b/ios/LChat/Views/AppUpdateBar.swift| new file mode 100644 |
| index 0000000..a77daf2 |
| --- /dev/null |
| +++ b/ios/LChat/Views/AppUpdateBar.swift |
| @@ -0,0 +1,30 @@ |
| +import SwiftUI |
| + |
| +// 🤖 The in-place offer for an app whose code changed while its surface was |
| +// on screen (FeedWebCache.handleAppsChanged): a page can't swap app code in |
| +// place, and reloading it out from under someone is worse than asking — so |
| +// the pane grows this bar inline, occupying layout space above the webview |
| +// (never a floating overlay), with the web sidebar banner's copy and |
| +// choices. Reload picks the new version up now; Later hides the bar, and the |
| +// page still reloads on its next return from parked. |
| +struct AppUpdateBar: View { |
| + @ObservedObject var bridge: WebBridge |
| + |
| + var body: some View { |
| + if let kind = bridge.appUpdateKind, !bridge.appUpdateDismissed { |
| + HStack(spacing: 16) { |
| + Text(kind == "updated" ? "App updated." : "Apps changed.") |
| + .font(.subheadline) |
| + Spacer(minLength: 0) |
| + Button("Reload") { bridge.reloadForAppUpdate() } |
| + .font(.subheadline.weight(.semibold)) |
| + Button("Later") { bridge.appUpdateDismissed = true } |
| + .font(.subheadline) |
| + } |
| + .padding(.horizontal, 14) |
| + .padding(.vertical, 10) |
| + .background(.bar) |
| + .overlay(Divider(), alignment: .bottom) |
| + } |
| + } |
| +} |
diff --git a/ios/LChat/Views/GroupAppPane.swift b/ios/LChat/Views/GroupAppPane.swift| index 098f0b2..bff1941 100644 |
| --- a/ios/LChat/Views/GroupAppPane.swift |
| +++ b/ios/LChat/Views/GroupAppPane.swift |
| @@ -30,11 +30,14 @@ struct GroupAppPane: View { |
| } |
| var body: some View { |
| - ZStack { |
| - GroupAppWebView(instance: instance, thread: thread, myUserId: myUserId, |
| - instanceKeyB64: instanceKeyB64, app: app) |
| - if !bridge.pageReady { |
| - WebLoadingVeil().transition(.opacity) |
| + VStack(spacing: 0) { |
| + AppUpdateBar(bridge: bridge) |
| + ZStack { |
| + GroupAppWebView(instance: instance, thread: thread, myUserId: myUserId, |
| + instanceKeyB64: instanceKeyB64, app: app) |
| + if !bridge.pageReady { |
| + WebLoadingVeil().transition(.opacity) |
| + } |
| } |
| } |
| .animation(.easeOut(duration: 0.25), value: bridge.pageReady) |
diff --git a/ios/LChat/Views/SpaceAppScreen.swift b/ios/LChat/Views/SpaceAppScreen.swift| index 8a6be75..26e94d5 100644 |
| --- a/ios/LChat/Views/SpaceAppScreen.swift |
| +++ b/ios/LChat/Views/SpaceAppScreen.swift |
| @@ -15,10 +15,9 @@ struct SpaceAppScreen: View { |
| Group { |
| if let thread = store.spaceApps.surfaceThread( |
| app: app, title: store.registry.title(app), glyph: store.registry.glyph(app)) { |
| - SpaceAppWebView(instance: store.instance, thread: thread, |
| + SpaceAppSurface(instance: store.instance, thread: thread, |
| myUserId: store.myUserId, |
| instanceKeyB64: store.instanceKey(), app: app) |
| - .ignoresSafeArea(edges: .bottom) |
| } else { |
| ContentUnavailableView("This app isn't ready yet", |
| systemImage: "square.grid.2x2", |
| @@ -30,6 +29,41 @@ struct SpaceAppScreen: View { |
| } |
| } |
| +// The webview plus its bridge-observing chrome: the inline app-update offer |
| +// (AppUpdateBar) above the pane, so a space app that changed while on screen |
| +// asks before reloading like every other app surface. |
| +private struct SpaceAppSurface: View { |
| + @ObservedObject private var bridge: WebBridge |
| + let instance: SavedInstance |
| + let thread: ChatThread |
| + let myUserId: String |
| + let instanceKeyB64: String? |
| + let app: String |
| + |
| + @MainActor |
| + init(instance: SavedInstance, thread: ChatThread, myUserId: String, |
| + instanceKeyB64: String?, app: String) { |
| + self.instance = instance |
| + self.thread = thread |
| + self.myUserId = myUserId |
| + self.instanceKeyB64 = instanceKeyB64 |
| + self.app = app |
| + bridge = FeedWebCache.shared.bridge(for: instance, thread: thread, |
| + myUserId: myUserId, |
| + instanceKeyB64: instanceKeyB64, |
| + app: app, space: true) |
| + } |
| + |
| + var body: some View { |
| + VStack(spacing: 0) { |
| + AppUpdateBar(bridge: bridge) |
| + SpaceAppWebView(instance: instance, thread: thread, myUserId: myUserId, |
| + instanceKeyB64: instanceKeyB64, app: app) |
| + .ignoresSafeArea(edges: .bottom) |
| + } |
| + } |
| +} |
| + |
| private struct SpaceAppWebView: UIViewRepresentable { |
| let instance: SavedInstance |
| let thread: ChatThread |
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift| index 2222d3c..4c22255 100644 |
| --- a/ios/LChat/Views/ThreadListView.swift |
| +++ b/ios/LChat/Views/ThreadListView.swift |
| @@ -477,9 +477,19 @@ struct ThreadListView: View { |
| // The app set changed (a custom/member app deployed or updated, |
| // anywhere): refresh the catalogue — through a command host, |
| // minted if no page is alive to answer (an all-native session |
| - // may have none). |
| + // may have none). An update or removal also refreshes every cached |
| + // app webview: a live page can't swap an app's code in place |
| + // (sandbox.js won't re-register a loaded id), so parked pages |
| + // reload invisibly and a visible one gets an inline reload offer |
| + // — without this a surface keeps running the old version until |
| + // its webview happens to be rebuilt. A brand-new app registers |
| + // live in-page, so "created" needs no reload. |
| if frame.type == "apps_changed" { |
| store.refreshAppCatalog() |
| + if frame.kind == "updated" || frame.kind == "removed" { |
| + FeedWebCache.shared.handleAppsChanged( |
| + instanceID: store.instance.id, kind: frame.kind ?? "updated") |
| + } |
| return |
| } |
| // A member lacks the instance key (#1376): if we hold it, wrap it |
diff --git a/ios/LChat/Views/WebBridge.swift b/ios/LChat/Views/WebBridge.swift| index 11a69d0..9ef923c 100644 |
| --- a/ios/LChat/Views/WebBridge.swift |
| +++ b/ios/LChat/Views/WebBridge.swift |
| @@ -33,6 +33,23 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| // a blank pane. |
| @Published var pageReady = false |
| + // An apps_changed update/removal arrived while this page was on screen |
| + // (FeedWebCache.handleAppsChanged): the page is running stale app code, |
| + // but yanking a surface someone is actively looking at is worse than |
| + // telling them — the pane renders an inline offer (AppUpdateBar) instead. |
| + // `appUpdateKind` is the frame's kind (picks the copy); "Later" only |
| + // hides the bar — the flag stays, so the cache still reloads the page |
| + // the next time it comes back from parked. |
| + @Published var appUpdateKind: String? |
| + @Published var appUpdateDismissed = false |
| + |
| + // The banner's Reload: pick up the new app code now. |
| + func reloadForAppUpdate() { |
| + appUpdateKind = nil |
| + appUpdateDismissed = false |
| + webView?.reload() |
| + } |
| + |
| func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { |
| pageReady = false |
| } |
diff --git a/ios/LChatMac/AppThreadWebView.swift b/ios/LChatMac/AppThreadWebView.swift| index 32baf2b..e547dea 100644 |
| --- a/ios/LChatMac/AppThreadWebView.swift |
| +++ b/ios/LChatMac/AppThreadWebView.swift |
| @@ -95,11 +95,14 @@ struct AppWebSurface: View { |
| } |
| var body: some View { |
| - ZStack { |
| - AppThreadWebView(instance: instance, thread: thread, myUserId: myUserId, |
| - instanceKeyB64: instanceKeyB64, app: app, space: space) |
| - if !bridge.pageReady { |
| - WebLoadingVeil().transition(.opacity) |
| + VStack(spacing: 0) { |
| + AppUpdateBar(bridge: bridge) |
| + ZStack { |
| + AppThreadWebView(instance: instance, thread: thread, myUserId: myUserId, |
| + instanceKeyB64: instanceKeyB64, app: app, space: space) |
| + if !bridge.pageReady { |
| + WebLoadingVeil().transition(.opacity) |
| + } |
| } |
| } |
| .animation(.easeOut(duration: 0.25), value: bridge.pageReady) |
| @@ -114,6 +117,36 @@ struct AppWebSurface: View { |
| } |
| } |
| +// 🤖 The in-place offer for an app whose code changed while its surface was |
| +// on screen (AppWebViewCache.handleAppsChanged): a page can't swap app code |
| +// in place, and reloading it out from under someone is worse than asking — |
| +// so the pane grows this bar inline, occupying layout space above the |
| +// webview (never a floating overlay), with the web sidebar banner's copy and |
| +// choices. Reload picks the new version up now; Later hides the bar, and the |
| +// page still reloads on its next return from parked. The Mac twin of iOS's |
| +// AppUpdateBar. |
| +struct AppUpdateBar: View { |
| + @ObservedObject var bridge: MacWebBridge |
| + |
| + var body: some View { |
| + if let kind = bridge.appUpdateKind, !bridge.appUpdateDismissed { |
| + HStack(spacing: 16) { |
| + Text(kind == "updated" ? "App updated." : "Apps changed.") |
| + .font(.subheadline) |
| + Spacer(minLength: 0) |
| + Button("Reload") { bridge.reloadForAppUpdate() } |
| + .font(.subheadline.weight(.semibold)) |
| + Button("Later") { bridge.appUpdateDismissed = true } |
| + .font(.subheadline) |
| + } |
| + .padding(.horizontal, 14) |
| + .padding(.vertical, 8) |
| + .background(.bar) |
| + .overlay(Divider(), alignment: .bottom) |
| + } |
| + } |
| +} |
| + |
| // The Mac cousin of iOS's WebBridge, scoped to what app panes need: the |
| // page-ready flag for the loading veil, the setGroupApp switch, and the |
| // target="_blank" fix (without a UI delegate those links fell on the floor — |
| @@ -131,6 +164,22 @@ final class MacWebBridge: NSObject, ObservableObject, WKNavigationDelegate, |
| // fallback (it fires when the HTML lands, before the app has drawn). |
| @Published var pageReady = false |
| + // An apps_changed update/removal arrived while this page was on screen |
| + // (AppWebViewCache.handleAppsChanged): the page is running stale app |
| + // code, but yanking a surface someone is actively looking at is worse |
| + // than telling them — the pane renders an inline offer (AppUpdateBar) |
| + // instead. "Later" only hides the bar — the flag stays, so the cache |
| + // still reloads the page the next time it comes back from parked. |
| + @Published var appUpdateKind: String? |
| + @Published var appUpdateDismissed = false |
| + |
| + // The banner's Reload: pick up the new app code now. |
| + func reloadForAppUpdate() { |
| + appUpdateKind = nil |
| + appUpdateDismissed = false |
| + webView?.reload() |
| + } |
| + |
| func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { |
| pageReady = false |
| } |
| @@ -245,6 +294,16 @@ final class AppWebViewCache { |
| if var e = entries[key], e.seedKey == thread.threadKeyB64 { |
| e.lastUsed = Date() |
| entries[key] = e |
| + // A deferred app update (flagged while on screen, or "Later"): |
| + // this access is (re)attaching a parked page — reload it now, |
| + // before it's back under someone's eyes. A page that's already |
| + // on screen (superview != nil, a SwiftUI re-render) keeps |
| + // running and keeps its banner. |
| + if e.bridge.appUpdateKind != nil, e.web.superview == nil { |
| + e.bridge.appUpdateKind = nil |
| + e.bridge.appUpdateDismissed = false |
| + e.web.reload() |
| + } |
| return e |
| } |
| if let old = entries.removeValue(forKey: key) { release(old) } |
| @@ -274,6 +333,28 @@ final class AppWebViewCache { |
| return e |
| } |
| + // An app's code changed under a live page (apps_changed, kind updated / |
| + // removed). A reload is the only way to swap — custom apps are ES modules |
| + // imported at boot, and sandbox.js refuses to re-register a member app id |
| + // a running page already loaded — but never reload a surface someone is |
| + // actively looking at: parked pages reload invisibly now, while a page |
| + // that's on screen gets flagged and its pane offers an inline reload |
| + // (AppUpdateBar). The embed seed survives the reload (it's a WKUserScript |
| + // on the configuration), so a page reboots straight back into its pinned |
| + // surface. |
| + func handleAppsChanged(instanceID: String, kind: String) { |
| + for (key, entry) in entries where key.hasPrefix(instanceID + "|") { |
| + if entry.web.superview != nil { |
| + entry.bridge.appUpdateKind = kind |
| + entry.bridge.appUpdateDismissed = false |
| + } else { |
| + entry.bridge.appUpdateKind = nil |
| + entry.bridge.appUpdateDismissed = false |
| + entry.web.reload() |
| + } |
| + } |
| + } |
| + |
| private func release(_ e: Entry) { |
| e.web.stopLoading() |
| // The controller retains the bridge; break that so the whole page tears down. |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index 12f6507..1288ba8 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -1673,9 +1673,19 @@ struct NativeChatView: View { |
| // The app set changed (a custom/member app deployed or updated, |
| // anywhere): refresh the catalogue — through a command host, |
| // minted if no page is alive to answer (an all-native session |
| - // may have none). |
| + // may have none). An update or removal also refreshes every cached |
| + // app webview: a live page can't swap an app's code in place |
| + // (sandbox.js won't re-register a loaded id), so parked pages |
| + // reload invisibly and a visible one gets an inline reload offer |
| + // — without this a surface keeps running the old version until |
| + // its webview happens to be rebuilt. A brand-new app registers |
| + // live in-page, so "created" needs no reload. |
| if frame.type == "apps_changed" { |
| store.refreshAppCatalog() |
| + if frame.kind == "updated" || frame.kind == "removed" { |
| + AppWebViewCache.shared.handleAppsChanged( |
| + instanceID: store.instance.id, kind: frame.kind ?? "updated") |
| + } |
| return |
| } |
| // A member lacks the instance key (#1376): if we hold it, wrap it |
diff --git a/tools/dev b/tools/dev| index f3cfaf4..0fa685a 100755 |
| --- a/tools/dev |
| +++ b/tools/dev |
| @@ -192,24 +192,25 @@ const commands = { |
| }, |
| "run mac": async () => { |
| - // Find the build output directory |
| + // Find the build output directory. Must pass the same -derivedDataPath |
| + // "build mac" builds with — without it xcodebuild reports the default |
| + // DerivedData location, where the app was never built, and launch fails. |
| + const macSettingsArgs = [ |
| + "-project", path.join(IOS_DIR, "LChat.xcodeproj"), |
| + "-scheme", "LChatMac", |
| + "-destination", "generic/platform=macOS", |
| + "-derivedDataPath", path.join(IOS_DIR, "build"), |
| + "-showBuildSettings", |
| + ]; |
| let buildSettingsOutput; |
| try { |
| - buildSettingsOutput = execFileSync("xcodebuild", [ |
| - "-project", path.join(IOS_DIR, "LChat.xcodeproj"), |
| - "-scheme", "LChatMac", |
| - "-destination", "generic/platform=macOS", |
| - "-showBuildSettings", |
| - ], { cwd: IOS_DIR, encoding: "utf8" }); |
| + buildSettingsOutput = execFileSync("xcodebuild", macSettingsArgs, |
| + { cwd: IOS_DIR, encoding: "utf8" }); |
| } catch (e) { |
| console.log("Build settings lookup failed. Building first..."); |
| await commands["build mac"](); |
| - buildSettingsOutput = execFileSync("xcodebuild", [ |
| - "-project", path.join(IOS_DIR, "LChat.xcodeproj"), |
| - "-scheme", "LChatMac", |
| - "-destination", "generic/platform=macOS", |
| - "-showBuildSettings", |
| - ], { cwd: IOS_DIR, encoding: "utf8" }); |
| + buildSettingsOutput = execFileSync("xcodebuild", macSettingsArgs, |
| + { cwd: IOS_DIR, encoding: "utf8" }); |
| } |
| const builtProductsDir = buildSettingsOutput |