Skip to content

Commit c439d12

Browse files
authored
Modern concurrency support (#175)
* Add @mainactor and Sendable annotations Enable strict concurrency Get backends to compile and update GTK backends Mac-specific fixes More sendability improvements (please tell me this compiles on Windows) I love waiting 15 minutes for CI to compile a 1-line change * Make Image Sendable use concurrency-annotated version of ImageFormats * Post-rebase fixes * Add more mainactor annotations to windows
1 parent fa6d364 commit c439d12

File tree

74 files changed

+332
-202
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

74 files changed

+332
-202
lines changed

Package.resolved

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ let package = Package(
9696
),
9797
.package(
9898
url: "https://github.com/stackotter/swift-image-formats",
99-
.upToNextMinor(from: "0.3.2")
99+
.upToNextMinor(from: "0.3.3")
100100
),
101101
.package(
102102
url: "https://github.com/stackotter/swift-windowsappsdk",
@@ -138,6 +138,9 @@ let package = Package(
138138
"Views/TupleViewChildren.swift.gyb",
139139
"Views/TableRowContent.swift.gyb",
140140
"Scenes/TupleScene.swift.gyb",
141+
],
142+
swiftSettings: [
143+
.enableUpcomingFeature("StrictConcurrency")
141144
]
142145
),
143146
.testTarget(

Sources/AppKitBackend/AppKitBackend.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public final class AppKitBackend: AppBackend {
4444
NSApplication.shared.delegate = appDelegate
4545
}
4646

47-
public func runMainLoop(_ callback: @escaping () -> Void) {
47+
public func runMainLoop(_ callback: @escaping @MainActor () -> Void) {
4848
callback()
4949
NSApplication.shared.activate(ignoringOtherApps: true)
5050
NSApplication.shared.run()
@@ -316,7 +316,7 @@ public final class AppKitBackend: AppBackend {
316316
NSApplication.shared.helpMenu = helpMenu
317317
}
318318

319-
public func runInMainThread(action: @escaping () -> Void) {
319+
public func runInMainThread(action: @escaping @MainActor () -> Void) {
320320
DispatchQueue.main.async {
321321
action()
322322
}

Sources/Gtk3Backend/Gtk3Backend.swift

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ public final class Gtk3Backend: AppBackend {
6464
gtkApp.registerSession = true
6565
}
6666

67-
public func runMainLoop(_ callback: @escaping () -> Void) {
67+
public func runMainLoop(_ callback: @escaping @MainActor () -> Void) {
6868
gtkApp.run { window in
6969
self.precreatedWindow = window
7070
callback()
@@ -344,14 +344,14 @@ public final class Gtk3Backend: AppBackend {
344344
}
345345

346346
class ThreadActionContext {
347-
var action: () -> Void
347+
var action: @MainActor () -> Void
348348

349-
init(action: @escaping () -> Void) {
349+
init(action: @escaping @MainActor () -> Void) {
350350
self.action = action
351351
}
352352
}
353353

354-
public func runInMainThread(action: @escaping () -> Void) {
354+
public func runInMainThread(action: @escaping @MainActor () -> Void) {
355355
let action = ThreadActionContext(action: action)
356356
g_idle_add_full(
357357
0,
@@ -360,9 +360,11 @@ public final class Gtk3Backend: AppBackend {
360360
fatalError("Gtk action callback called without context")
361361
}
362362

363-
let action = Unmanaged<ThreadActionContext>.fromOpaque(context)
364-
.takeUnretainedValue()
365-
action.action()
363+
MainActor.assumeIsolated {
364+
let action = Unmanaged<ThreadActionContext>.fromOpaque(context)
365+
.takeUnretainedValue()
366+
action.action()
367+
}
366368

367369
return 0
368370
},
@@ -384,9 +386,11 @@ public final class Gtk3Backend: AppBackend {
384386
fatalError("Gtk action callback called without context")
385387
}
386388

387-
let action = Unmanaged<ThreadActionContext>.fromOpaque(context)
388-
.takeUnretainedValue()
389-
action.action()
389+
MainActor.assumeIsolated {
390+
let action = Unmanaged<ThreadActionContext>.fromOpaque(context)
391+
.takeUnretainedValue()
392+
action.action()
393+
}
390394

391395
// Cancel the recurring timeout after one iteration
392396
return 0

Sources/GtkBackend/GtkBackend.swift

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public final class GtkBackend: AppBackend {
6565

6666
var globalCSSProvider: CSSProvider?
6767

68-
public func runMainLoop(_ callback: @escaping () -> Void) {
68+
public func runMainLoop(_ callback: @escaping @MainActor () -> Void) {
6969
gtkApp.run { window in
7070
self.precreatedWindow = window
7171
callback()
@@ -315,14 +315,14 @@ public final class GtkBackend: AppBackend {
315315
}
316316

317317
class ThreadActionContext {
318-
var action: () -> Void
318+
var action: @MainActor () -> Void
319319

320-
init(action: @escaping () -> Void) {
320+
init(action: @escaping @MainActor () -> Void) {
321321
self.action = action
322322
}
323323
}
324324

325-
public func runInMainThread(action: @escaping () -> Void) {
325+
public func runInMainThread(action: @escaping @MainActor () -> Void) {
326326
let action = ThreadActionContext(action: action)
327327
g_idle_add_full(
328328
0,
@@ -331,9 +331,11 @@ public final class GtkBackend: AppBackend {
331331
fatalError("Gtk action callback called without context")
332332
}
333333

334-
let action = Unmanaged<ThreadActionContext>.fromOpaque(context)
335-
.takeUnretainedValue()
336-
action.action()
334+
MainActor.assumeIsolated {
335+
let action = Unmanaged<ThreadActionContext>.fromOpaque(context)
336+
.takeUnretainedValue()
337+
action.action()
338+
}
337339

338340
return 0
339341
},
@@ -355,9 +357,11 @@ public final class GtkBackend: AppBackend {
355357
fatalError("Gtk action callback called without context")
356358
}
357359

358-
let action = Unmanaged<ThreadActionContext>.fromOpaque(context)
359-
.takeUnretainedValue()
360-
action.action()
360+
MainActor.assumeIsolated {
361+
let action = Unmanaged<ThreadActionContext>.fromOpaque(context)
362+
.takeUnretainedValue()
363+
action.action()
364+
}
361365

362366
// Cancel the recurring timeout after one iteration
363367
return 0

Sources/HotReloadingMacrosPlugin/HotReloadableAppMacro.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ extension HotReloadableAppMacro: MemberMacro {
104104
105105
if !hotReloadingHasConnectedToServer {
106106
hotReloadingHasConnectedToServer = true
107-
Task {
107+
Task { @MainActor
108108
do {
109109
var client = try await HotReloadingClient()
110110
print("Hot reloading: received new dylib")

Sources/SwiftCrossUI/App.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Foundation
22

33
/// An application.
4+
@MainActor
45
public protocol App {
56
/// The backend used to render the app.
67
associatedtype Backend: AppBackend
@@ -24,14 +25,16 @@ public protocol App {
2425

2526
/// Force refresh the entire scene graph. Used by hot reloading. If you need to do
2627
/// this in your own code then something has gone very wrong...
28+
@MainActor
2729
public var _forceRefresh: () -> Void = {}
2830

2931
/// Metadata embedded by Swift Bundler if present. Loaded at app start up.
32+
@MainActor
3033
private var swiftBundlerAppMetadata: AppMetadata?
3134

3235
/// An error encountered when parsing Swift Bundler metadata.
3336
private enum SwiftBundlerMetadataError: LocalizedError {
34-
case jsonNotDictionary(Any)
37+
case jsonNotDictionary(String)
3538
case missingAppIdentifier
3639
case missingAppVersion
3740

@@ -94,7 +97,7 @@ extension App {
9497
// require a lot of boilerplate code to parse with Codable).
9598
let jsonValue = try JSONSerialization.jsonObject(with: jsonData)
9699
guard let json = jsonValue as? [String: Any] else {
97-
throw SwiftBundlerMetadataError.jsonNotDictionary(jsonValue)
100+
throw SwiftBundlerMetadataError.jsonNotDictionary(String(describing: jsonValue))
98101
}
99102
guard let identifier = json["appIdentifier"] as? String else {
100103
throw SwiftBundlerMetadataError.missingAppIdentifier

Sources/SwiftCrossUI/Backend/AppBackend.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ import Foundation
4040
/// code between the `create` and `update` methods of the various widgets
4141
/// (since the `update` method is always called between calling `create`
4242
/// and actually displaying the widget anyway).
43-
public protocol AppBackend {
43+
@MainActor
44+
public protocol AppBackend: Sendable {
4445
associatedtype Window
4546
associatedtype Widget
4647
associatedtype Menu
@@ -120,7 +121,7 @@ public protocol AppBackend {
120121
/// setup function is passed to `Gtk` as a callback to run once the main
121122
/// run loop starts.
122123
func runMainLoop(
123-
_ callback: @escaping () -> Void
124+
_ callback: @escaping @MainActor () -> Void
124125
)
125126
/// Creates a new window. For some backends it may make sense for this
126127
/// method to return the application's root window the first time its
@@ -174,7 +175,7 @@ public protocol AppBackend {
174175
/// Runs an action in the app's main thread if required to perform UI updates
175176
/// by the backend. Predominantly used by ``Publisher`` to publish changes to a thread
176177
/// compatible with dispatching UI updates. Can be synchronous or asynchronous (for now).
177-
func runInMainThread(action: @escaping () -> Void)
178+
nonisolated func runInMainThread(action: @escaping @MainActor () -> Void)
178179

179180
/// Computes the root environment for an app (e.g. by checking the system's current
180181
/// theme). May fall back on the provided defaults where reasonable.
@@ -193,7 +194,7 @@ public protocol AppBackend {
193194
/// A default implementation is provided. It uses the backend's reported
194195
/// device class and looks up the text style in a lookup table derived
195196
/// from Apple's typography guidelines. See ``TextStyle/resolve(for:)``.
196-
@Sendable func resolveTextStyle(_ textStyle: Font.TextStyle) -> Font.TextStyle.Resolved
197+
func resolveTextStyle(_ textStyle: Font.TextStyle) -> Font.TextStyle.Resolved
197198

198199
/// Computes a window's environment based off the root environment. This may involve
199200
/// updating ``EnvironmentValues/windowScaleFactor`` etc.
@@ -683,7 +684,6 @@ public protocol AppBackend {
683684
}
684685

685686
extension AppBackend {
686-
@Sendable
687687
public func resolveTextStyle(
688688
_ textStyle: Font.TextStyle
689689
) -> Font.TextStyle.Resolved {

Sources/SwiftCrossUI/Backend/ResolvedMenu.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public struct ResolvedMenu {
1616
/// A menu item.
1717
public enum Item {
1818
/// A button. A `nil` action means that the button is disabled.
19-
case button(_ label: String, _ action: (() -> Void)?)
19+
case button(_ label: String, _ action: (@MainActor () -> Void)?)
2020
/// A named submenu.
2121
case submenu(Submenu)
2222
}

Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
/// This exists to avoid having to expose internal details of ``Button``, since
55
/// breaking ``Button``'s API would have much more wide-reaching impacts than
66
/// breaking this single-purpose API.
7-
public struct AlertAction {
7+
public struct AlertAction: Sendable {
88
public static let ok = AlertAction(label: "Ok", action: {})
99

1010
public var label: String
11-
public var action: () -> Void
11+
public var action: @MainActor @Sendable () -> Void
1212
}

Sources/SwiftCrossUI/Environment/Actions/OpenURLAction.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Foundation
22

33
/// Opens a URL with the default application. May present an application picker
44
/// if multiple applications are registered for the given URL protocol.
5+
@MainActor
56
public struct OpenURLAction {
67
let action: (URL) -> Void
78

Sources/SwiftCrossUI/Environment/Actions/PresentAlertAction.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
/// selected action. By default, the alert will have a single button labelled
44
/// `OK`. All buttons will dismiss the alert even if you provide your own
55
/// actions.
6+
@MainActor
67
public struct PresentAlertAction {
78
let environment: EnvironmentValues
89

Sources/SwiftCrossUI/Environment/Actions/PresentFileSaveDialogAction.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import Foundation
22

33
/// Presents a 'Save file' dialog fit for selecting a save destination. Returns
44
/// `nil` if the user cancels the operation.
5-
public struct PresentFileSaveDialogAction {
5+
public struct PresentFileSaveDialogAction: Sendable {
66
let backend: any AppBackend
7-
let window: Any?
7+
let window: MainActorBox<Any?>
88

99
public func callAsFunction(
1010
title: String = "Save",
@@ -19,7 +19,7 @@ public struct PresentFileSaveDialogAction {
1919
return await withCheckedContinuation { continuation in
2020
backend.runInMainThread {
2121
let window: Backend.Window? =
22-
if let window = self.window {
22+
if let window = self.window.value {
2323
.some(window as! Backend.Window)
2424
} else {
2525
nil

Sources/SwiftCrossUI/Environment/Actions/PresentSingleFileOpenDialogAction.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import Foundation
33
/// Presents an 'Open file' dialog fit for selecting a single file. Some
44
/// backends only allow selecting either files or directories but not both
55
/// in a single dialog. Returns `nil` if the user cancels the operation.
6-
public struct PresentSingleFileOpenDialogAction {
6+
public struct PresentSingleFileOpenDialogAction: Sendable {
77
let backend: any AppBackend
8-
let window: Any?
8+
let window: MainActorBox<Any?>
99

1010
public func callAsFunction(
1111
title: String = "Open",
@@ -20,7 +20,7 @@ public struct PresentSingleFileOpenDialogAction {
2020
await withCheckedContinuation { continuation in
2121
backend.runInMainThread {
2222
let window: Backend.Window? =
23-
if let window = self.window {
23+
if let window = self.window.value {
2424
.some(window as! Backend.Window)
2525
} else {
2626
nil

Sources/SwiftCrossUI/Environment/Actions/RevealFileAction.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Foundation
22

33
/// Reveals a file in the system's file manager. This opens
44
/// the file's enclosing directory and highlighting the file.
5+
@MainActor
56
public struct RevealFileAction {
67
let action: (URL) -> Void
78

0 commit comments

Comments
 (0)