eleven / ocho Public

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>
James Adam b176e8f8f96373ff16213c186fe0d9a8071b5355 parent 75eee62
10 files changed, +259 −35
  • ios/LChat/FeedWebCache.swift +38 −3
  • ios/LChat/Views/AppThreadScreen.swift +8 −5
  • ios/LChat/Views/AppUpdateBar.swift +30 −0
  • ios/LChat/Views/GroupAppPane.swift +8 −5
  • ios/LChat/Views/SpaceAppScreen.swift +36 −2
  • ios/LChat/Views/ThreadListView.swift +11 −1
  • ios/LChat/Views/WebBridge.swift +17 −0
  • ios/LChatMac/AppThreadWebView.swift +86 −5
  • ios/LChatMac/NativeChatView.swift +11 −1
  • tools/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