From 6a0f97737f18a4addfe245bb1acb3dca0a41b686 Mon Sep 17 00:00:00 2001 From: Daisuke Murase Date: Thu, 26 Feb 2026 19:48:29 -0800 Subject: [PATCH] fix: process state change events synchronously on main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When screenParametersChanged fires, set_displays() migrates nodes in Rust and calls notify() → onStateChange() on the main thread. Previously, onStateChange always used DispatchQueue.main.async, so the migration events were queued rather than processed immediately. This caused handleDisplayChange (which runs right after set_displays returns) to read stale Swift-side nodes, creating windows on the wrong display. Fix: use MainActor.assumeIsolated to process events synchronously when already on the main thread, ensuring nodes are updated before handleDisplayChange runs. Background thread callbacks (from IPC server) continue to use DispatchQueue.main.async as before. --- Cargo.lock | 6 ++-- app/Sources/BarViewModel.swift | 61 ++++++++++++++++++++-------------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a4bc1cd..31d65df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,7 +452,7 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "ranma-cli" -version = "0.1.9" +version = "0.1.10" dependencies = [ "argh", "libc", @@ -462,7 +462,7 @@ dependencies = [ [[package]] name = "ranma-core" -version = "0.1.9" +version = "0.1.10" dependencies = [ "parking_lot", "serde", @@ -737,7 +737,7 @@ dependencies = [ [[package]] name = "uniffi-bindgen" -version = "0.1.9" +version = "0.1.10" dependencies = [ "uniffi", ] diff --git a/app/Sources/BarViewModel.swift b/app/Sources/BarViewModel.swift index 62da9bc..0932b4f 100644 --- a/app/Sources/BarViewModel.swift +++ b/app/Sources/BarViewModel.swift @@ -19,33 +19,44 @@ final class BarViewModel: StateChangeHandler, @unchecked Sendable { private var fullscreenDisplays: Set = [] func onStateChange(event: StateChangeEvent) throws { - DispatchQueue.main.async { [self] in - switch event { - case let .nodeAdded(display, node): - nodes[display, default: []].append(node) - scheduleRefresh(display) - - case let .nodeRemoved(display, _): - let updated = getNodesForDisplay(display: display) - nodes[display] = updated - scheduleRefresh(display) - - case let .nodeUpdated(display, node): - if let idx = nodes[display]?.firstIndex(where: { $0.name == node.name }) { - nodes[display]?[idx] = node - } - scheduleRefresh(display) - - case let .nodeMoved(oldDisplay, newDisplay, node): - nodes[oldDisplay]?.removeAll { $0.name == node.name } - scheduleRefresh(oldDisplay) - nodes[newDisplay, default: []].append(node) - scheduleRefresh(newDisplay) + if Thread.isMainThread { + MainActor.assumeIsolated { + handleEvent(event) + } + } else { + DispatchQueue.main.async { [self] in + handleEvent(event) + } + } + } - case let .fullRefresh(display, newNodes): - nodes[display] = newNodes - scheduleRefresh(display) + @MainActor + private func handleEvent(_ event: StateChangeEvent) { + switch event { + case let .nodeAdded(display, node): + nodes[display, default: []].append(node) + scheduleRefresh(display) + + case let .nodeRemoved(display, _): + let updated = getNodesForDisplay(display: display) + nodes[display] = updated + scheduleRefresh(display) + + case let .nodeUpdated(display, node): + if let idx = nodes[display]?.firstIndex(where: { $0.name == node.name }) { + nodes[display]?[idx] = node } + scheduleRefresh(display) + + case let .nodeMoved(oldDisplay, newDisplay, node): + nodes[oldDisplay]?.removeAll { $0.name == node.name } + scheduleRefresh(oldDisplay) + nodes[newDisplay, default: []].append(node) + scheduleRefresh(newDisplay) + + case let .fullRefresh(display, newNodes): + nodes[display] = newNodes + scheduleRefresh(display) } }