From 34e823270edb2a1d4bdab4271593ccdeb7572101 Mon Sep 17 00:00:00 2001 From: imaznation Date: Wed, 20 May 2026 20:44:34 -0700 Subject: [PATCH] fix: move PluginWidgetManifest.write off main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproducible startup hang: EdgeControl.main() blocks on PluginWidgetManifest.write() → Data.write(.atomic) → __open syscall inside the app-group container. App never reaches app.run(), no window ever appears. Cause is environmental (sandbox / container-manager state we don't control) — the .write() syscall just never completes for some users. Even with no plugin desktop widgets installed, the empty-manifest write happens on every launch and was the gating call. Fix: - Move the entire write dance (mkdir + freshness check + atomic write) onto DispatchQueue.global(qos: .utility). Fire-and-forget. Main thread proceeds to app.run() instantly. - Skip the write entirely if the on-disk file already matches the new content (avoids a no-op write on every launch). The widget extension polls plugins.json, so a one-tick stale manifest is fine. Plugin renderer also self-corrects on first periodic refresh. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/EdgeControl/App/EdgeControlApp.swift | 4 +++- Sources/EdgeControl/Models/WidgetData.swift | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Sources/EdgeControl/App/EdgeControlApp.swift b/Sources/EdgeControl/App/EdgeControlApp.swift index 2af62ed..531ad65 100644 --- a/Sources/EdgeControl/App/EdgeControlApp.swift +++ b/Sources/EdgeControl/App/EdgeControlApp.swift @@ -151,7 +151,9 @@ enum EdgeControlExecutable { widgetBridge.start() model.widgetDataBridge = widgetBridge - // Plugin desktop widget renderer: headless WKWebView snapshots + // Plugin desktop widget renderer: headless WKWebView snapshots. + // PluginWidgetManifest.write() now runs its disk I/O on a + // background queue so this call no longer gates start(). let pluginRenderer = PluginWidgetRenderer(pluginManager: pluginManager, model: model) pluginRenderer.start() model.pluginWidgetRenderer = pluginRenderer diff --git a/Sources/EdgeControl/Models/WidgetData.swift b/Sources/EdgeControl/Models/WidgetData.swift index a654b02..27b5107 100644 --- a/Sources/EdgeControl/Models/WidgetData.swift +++ b/Sources/EdgeControl/Models/WidgetData.swift @@ -148,10 +148,20 @@ public struct PluginWidgetManifest: Codable, Sendable { public func write() { guard let dir = Self.containerDirectory else { return } - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let url = dir.appendingPathComponent("plugins.json") guard let data = try? JSONEncoder.widgetEncoder.encode(self) else { return } - try? data.write(to: url, options: .atomic) + // App-group container writes have been observed hanging the calling + // thread on __open inside Foundation's atomic-write path (sandbox / + // container-manager state we don't control). Move the entire dance + // — directory creation, freshness check, atomic write — to a + // background queue with a fire-and-forget contract so the main + // thread never blocks on this call. Worst case the manifest is a + // tick stale, which is fine for desktop widgets that already poll. + DispatchQueue.global(qos: .utility).async { + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("plugins.json") + if let existing = try? Data(contentsOf: url), existing == data { return } + try? data.write(to: url, options: .atomic) + } } /// Get snapshot image path for a plugin at a given size