Skip to content

Commit 778cded

Browse files
authored
refactor: migrate to Swift 6 language mode with strict concurrency (farouqaldori#47)
* refactor(build): migrate to Swift 6 language mode Enable Swift 6 with strict concurrency checking: - Update SWIFT_VERSION from 5.0 to 6 in Debug and Release configs - Add SWIFT_STRICT_CONCURRENCY = complete - Add @preconcurrency imports for Sparkle and Markdown libraries - Add @mainactor to AppDelegate class for global state isolation Build errors are expected and will be fixed in subsequent commits. * fix(concurrency): replace NSLock with Mutex for thread-safe task tracking Migrate from NSLock to Swift 6 Synchronization.Mutex for the activeTasks dictionary in ClaudeSessionMonitor. Mutex provides better ergonomics with withLock closures and aligns with modern Swift concurrency patterns. * fix(concurrency): convert ProcessExecutor from actor to struct ProcessExecutor has no mutable state to protect - all methods dispatch work to background queues and return results via async continuations. Converting to a Sendable struct eliminates unnecessary actor isolation overhead and simplifies cross-context access in Swift 6. * fix(concurrency): use MainActor.assumeIsolated for event handlers NSEvent monitor handlers and NotificationCenter handlers run on the main thread by documentation. Replace DispatchQueue.main.async with MainActor.assumeIsolated for better performance and to avoid Sendable warnings when passing NSEvent across isolation boundaries. Also add nonisolated(unsafe) to ScreenObserver properties that are only written during init (on MainActor) and read from deinit. * fix(concurrency): fix MainActor isolation for timer callbacks and loggers - Wrap Sparkle update check in Timer callback with Task { @mainactor } to ensure SPUUpdater methods are called on MainActor - Mark global Logger instances as nonisolated(unsafe) for cross-context access from actors and nonisolated functions * fix(concurrency): add nonisolated annotations for Sendable types Add explicit nonisolated markers to methods, computed properties, and static functions on Sendable value types. Swift 6 strict concurrency requires these when accessed from non-isolated contexts: - SessionPhase: init, isWaitingForApproval, matches(), allowedTransitions() - SessionState: ToolTracker and SubagentState methods - ToolResultData: MCPResult and GenericResult with nonisolated(unsafe) for [String: Any] properties and nonisolated == operators - ChatItemFactory: all static factory methods - ToolResultParser: all static parsing methods - TerminalAppRegistry: static properties and methods - EventMonitor: stopInternal() for deinit access * fix(deprecation): update deprecated API calls - Replace NSRunningApplication.activate(options:) with activate() as the options parameter is deprecated in macOS 14+ - Silence unused result warning for NSSound.play() with explicit discard * fix(lint): remove redundant conditional casts in HookInstaller Simplify array iteration by removing unnecessary conditional casts. Since the arrays are already typed as [[String: Any]], re-casting each element is redundant and triggers Swift 6 warnings. * fix(concurrency): make file watcher classes @unchecked Sendable AgentFileWatcher and JSONLInterruptWatcher use their own DispatchQueues for serialization rather than MainActor. Mark them as @unchecked Sendable with nonisolated methods and nonisolated(unsafe) properties to satisfy Swift 6 strict concurrency checking. - Add final class with @unchecked Sendable conformance - Mark all methods as nonisolated - Mark mutable properties as nonisolated(unsafe) (protected by queue) - Add nonisolated(unsafe) to file-level logger (MainActor inference workaround) * fix(concurrency): make HookSocketServer @unchecked Sendable with explicit Codable HookSocketServer uses its own DispatchQueue and NSLock for thread safety. Mark it as @unchecked Sendable with proper nonisolated annotations for Swift 6 strict concurrency. - Add final class with @unchecked Sendable conformance - Mark all public and private methods as nonisolated - Mark mutable properties as nonisolated(unsafe) (protected by queue/locks) - Add explicit nonisolated Codable conformance for HookEvent and HookResponse to avoid MainActor-isolated synthesized conformance - Add nonisolated(unsafe) to file-level logger (MainActor inference workaround) * fix(concurrency): replace nonisolated(unsafe) with nonisolated for Sendable constants Move module-level loggers inside their types as `nonisolated static let` per SE-0434. Sendable constants don't need the `unsafe` qualifier since they're inherently thread-safe. This eliminates 7 compiler warnings. * fix(concurrency): make ScreenObserver @unchecked Sendable Add @unchecked Sendable conformance to ScreenObserver for consistency with other classes using nonisolated(unsafe) properties (AgentFileWatcher, JSONLInterruptWatcher). Thread safety is managed via main thread notification delivery and debounced DispatchWorkItem execution. * refactor(concurrency): migrate HookSocketServer from NSLock to Mutex Replace legacy NSLock + nonisolated(unsafe) pattern with modern Synchronization.Mutex for thread-safe state management. - Add PermissionsState and CacheState structs for Mutex-protected state - Replace permissionsLock/cacheLock NSLock with Mutex instances - Convert all critical sections to use withLock closures - Add TimeoutResult and PermissionLookupResult enums for clean multi-guard early returns outside lock scope - Convert markPermissionResponded to nonisolated static helper * fix(concurrency): address race conditions and Swift 6 isolation - Move acceptSource mutations into queue.sync in stop() to avoid data races - Remove unnecessary MainActor hop in scheduleRetry() to prevent deadlock - Use explicit MainActor isolation for delegate callbacks in file watchers
1 parent 3cc15b7 commit 778cded

23 files changed

Lines changed: 530 additions & 408 deletions

ClaudeIsland.xcodeproj/project.pbxproj

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,8 @@
306306
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
307307
SWIFT_EMIT_LOC_STRINGS = YES;
308308
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
309-
SWIFT_VERSION = 5.0;
309+
SWIFT_STRICT_CONCURRENCY = complete;
310+
SWIFT_VERSION = 6;
310311
};
311312
name = Debug;
312313
};
@@ -341,7 +342,8 @@
341342
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
342343
SWIFT_EMIT_LOC_STRINGS = YES;
343344
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
344-
SWIFT_VERSION = 5.0;
345+
SWIFT_STRICT_CONCURRENCY = complete;
346+
SWIFT_VERSION = 6;
345347
};
346348
name = Release;
347349
};

ClaudeIsland/App/AppDelegate.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import AppKit
22
import os
3-
import Sparkle
3+
@preconcurrency import Sparkle
44
import SwiftUI
55

6+
/// Logger for app delegate
67
private let logger = Logger(subsystem: "com.engels74.ClaudeIsland", category: "AppDelegate")
78

89
// MARK: - AppDelegate
910

11+
@MainActor
1012
class AppDelegate: NSObject, NSApplicationDelegate {
1113
// MARK: Lifecycle
1214

@@ -68,8 +70,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6870
}
6971

7072
self.updateCheckTimer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in
71-
guard let updater = self?.updater, updater.canCheckForUpdates else { return }
72-
updater.checkForUpdates()
73+
Task { @MainActor [weak self] in
74+
guard let updater = self?.updater, updater.canCheckForUpdates else { return }
75+
updater.checkForUpdates()
76+
}
7377
}
7478
}
7579

ClaudeIsland/App/ScreenObserver.swift

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88
import AppKit
99

10-
class ScreenObserver {
10+
/// `@unchecked Sendable` because thread safety is managed via main thread
11+
/// notification delivery and debounced DispatchWorkItem execution
12+
final class ScreenObserver: @unchecked Sendable {
1113
// MARK: Lifecycle
1214

1315
init(onScreenChange: @escaping () -> Void) {
@@ -21,9 +23,13 @@ class ScreenObserver {
2123

2224
// MARK: Private
2325

24-
private var observer: Any?
25-
private let onScreenChange: () -> Void
26-
private var pendingWork: DispatchWorkItem?
26+
/// nonisolated(unsafe) is safe here because:
27+
/// 1. These are only written in startObserving() which runs on init (implicitly @MainActor)
28+
/// 2. They are read in stopObserving() which is either called from @MainActor or from deinit
29+
/// when there are no other references
30+
private nonisolated(unsafe) var observer: Any?
31+
private nonisolated(unsafe) let onScreenChange: () -> Void
32+
private nonisolated(unsafe) var pendingWork: DispatchWorkItem?
2733

2834
/// Debounce interval to coalesce rapid screen change notifications
2935
/// (e.g., when waking from sleep, displays reconnect in stages)
@@ -35,7 +41,9 @@ class ScreenObserver {
3541
object: nil,
3642
queue: .main
3743
) { [weak self] _ in
38-
self?.scheduleScreenChange()
44+
MainActor.assumeIsolated {
45+
self?.scheduleScreenChange()
46+
}
3947
}
4048
}
4149

@@ -53,7 +61,7 @@ class ScreenObserver {
5361
)
5462
}
5563

56-
private func stopObserving() {
64+
private nonisolated func stopObserving() {
5765
self.pendingWork?.cancel()
5866
if let observer {
5967
NotificationCenter.default.removeObserver(observer)

ClaudeIsland/Events/EventMonitor.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ final class EventMonitor: @unchecked Sendable {
5959
private let handler: @Sendable (NSEvent) -> Void
6060

6161
/// Internal stop that can be called from deinit
62-
private func stopInternal() {
62+
private nonisolated func stopInternal() {
6363
if let monitor = globalMonitor {
6464
NSEvent.removeMonitor(monitor)
6565
self.globalMonitor = nil

ClaudeIsland/Events/EventMonitors.swift

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@
88
import AppKit
99
import Combine
1010

11+
// MARK: - SendableEvent
12+
13+
/// Wrapper to safely pass NSEvent across MainActor boundaries.
14+
/// Safe because NSEvent monitor handlers are documented to run on main thread.
15+
private struct SendableEvent: @unchecked Sendable {
16+
nonisolated(unsafe) let event: NSEvent
17+
}
18+
19+
// MARK: - EventMonitors
20+
1121
/// Singleton that aggregates all event monitors.
1222
/// @MainActor ensures thread-safe access to mutable state and Combine publishers
1323
/// since NSEvent monitors dispatch handlers on the main thread.
@@ -33,25 +43,26 @@ final class EventMonitors {
3343
private var mouseDraggedMonitor: EventMonitor?
3444

3545
private func setupMonitors() {
36-
// Note: Apple documents that NSEvent monitor handlers run on the main thread.
37-
// Using DispatchQueue.main.async provides defensive safety in case this ever changes,
38-
// avoiding potential crashes from MainActor.assumeIsolated violations.
46+
// NSEvent monitor handlers are documented to run on the main thread.
47+
// Using MainActor.assumeIsolated is safe and avoids Swift 6 Sendable warnings
48+
// when passing NSEvent across isolation boundaries.
3949
self.mouseMoveMonitor = EventMonitor(mask: .mouseMoved) { [weak self] _ in
40-
DispatchQueue.main.async {
50+
MainActor.assumeIsolated {
4151
self?.mouseLocation.send(NSEvent.mouseLocation)
4252
}
4353
}
4454
self.mouseMoveMonitor?.start()
4555

4656
self.mouseDownMonitor = EventMonitor(mask: .leftMouseDown) { [weak self] event in
47-
DispatchQueue.main.async {
48-
self?.mouseDown.send(event)
57+
let wrapper = SendableEvent(event: event)
58+
MainActor.assumeIsolated {
59+
self?.mouseDown.send(wrapper.event)
4960
}
5061
}
5162
self.mouseDownMonitor?.start()
5263

5364
self.mouseDraggedMonitor = EventMonitor(mask: .leftMouseDragged) { [weak self] _ in
54-
DispatchQueue.main.async {
65+
MainActor.assumeIsolated {
5566
self?.mouseLocation.send(NSEvent.mouseLocation)
5667
}
5768
}

ClaudeIsland/Models/SessionPhase.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ struct PermissionContext: Sendable {
1717
// MARK: Lifecycle
1818

1919
/// Initialize with raw tool input dictionary (serializes to JSON)
20-
init(toolUseID: String, toolName: String, toolInput: [String: AnyCodable]?, receivedAt: Date) {
20+
nonisolated init(toolUseID: String, toolName: String, toolInput: [String: AnyCodable]?, receivedAt: Date) {
2121
self.toolUseID = toolUseID
2222
self.toolName = toolName
2323
self.receivedAt = receivedAt
@@ -150,7 +150,7 @@ enum SessionPhase: Sendable {
150150
}
151151

152152
/// Whether this is a waitingForApproval phase
153-
var isWaitingForApproval: Bool {
153+
nonisolated var isWaitingForApproval: Bool {
154154
if case .waitingForApproval = self {
155155
return true
156156
}
@@ -197,7 +197,7 @@ enum SessionPhase: Sendable {
197197

198198
// MARK: Internal
199199

200-
func matches(_ phase: SessionPhase) -> Bool {
200+
nonisolated func matches(_ phase: SessionPhase) -> Bool {
201201
switch (self, phase) {
202202
case (.idle, .idle),
203203
(.processing, .processing),
@@ -213,7 +213,7 @@ enum SessionPhase: Sendable {
213213
}
214214

215215
/// Valid transitions from each phase
216-
private static func allowedTransitions(from phase: Self) -> [PhaseKey] {
216+
private nonisolated static func allowedTransitions(from phase: Self) -> [PhaseKey] {
217217
switch phase {
218218
case .idle:
219219
// Note: .waitingForInput is allowed for history loading where we discover actual state

ClaudeIsland/Models/SessionState.swift

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -234,17 +234,17 @@ struct ToolTracker: Equatable, Sendable {
234234
var lastSyncTime: Date?
235235

236236
/// Mark a tool ID as seen, returns true if it was new
237-
mutating func markSeen(_ id: String) -> Bool {
237+
nonisolated mutating func markSeen(_ id: String) -> Bool {
238238
self.seenIDs.insert(id).inserted
239239
}
240240

241241
/// Check if a tool ID has been seen
242-
func hasSeen(_ id: String) -> Bool {
242+
nonisolated func hasSeen(_ id: String) -> Bool {
243243
self.seenIDs.contains(id)
244244
}
245245

246246
/// Start tracking a tool
247-
mutating func startTool(id: String, name: String) {
247+
nonisolated mutating func startTool(id: String, name: String) {
248248
guard self.markSeen(id) else { return }
249249
self.inProgress[id] = ToolInProgress(
250250
id: id,
@@ -255,7 +255,7 @@ struct ToolTracker: Equatable, Sendable {
255255
}
256256

257257
/// Complete a tool
258-
mutating func completeTool(id: String, success: Bool) {
258+
nonisolated mutating func completeTool(id: String, success: Bool) {
259259
self.inProgress.removeValue(forKey: id)
260260
}
261261
}
@@ -304,12 +304,12 @@ struct SubagentState: Equatable, Sendable {
304304
var agentDescriptions: [String: String]
305305

306306
/// Whether there's an active subagent
307-
var hasActiveSubagent: Bool {
307+
nonisolated var hasActiveSubagent: Bool {
308308
!self.activeTasks.isEmpty
309309
}
310310

311311
/// Start tracking a Task tool
312-
mutating func startTask(taskToolID: String, description: String? = nil) {
312+
nonisolated mutating func startTask(taskToolID: String, description: String? = nil) {
313313
self.activeTasks[taskToolID] = TaskContext(
314314
taskToolID: taskToolID,
315315
startTime: Date(),
@@ -320,30 +320,30 @@ struct SubagentState: Equatable, Sendable {
320320
}
321321

322322
/// Stop tracking a Task tool
323-
mutating func stopTask(taskToolID: String) {
323+
nonisolated mutating func stopTask(taskToolID: String) {
324324
self.activeTasks.removeValue(forKey: taskToolID)
325325
}
326326

327327
/// Set the agentID for a Task (called when agent file is discovered)
328-
mutating func setAgentID(_ agentID: String, for taskToolID: String) {
328+
nonisolated mutating func setAgentID(_ agentID: String, for taskToolID: String) {
329329
self.activeTasks[taskToolID]?.agentID = agentID
330330
if let description = activeTasks[taskToolID]?.description {
331331
self.agentDescriptions[agentID] = description
332332
}
333333
}
334334

335335
/// Add a subagent tool to a specific Task by ID
336-
mutating func addSubagentToolToTask(_ tool: SubagentToolCall, taskID: String) {
336+
nonisolated mutating func addSubagentToolToTask(_ tool: SubagentToolCall, taskID: String) {
337337
self.activeTasks[taskID]?.subagentTools.append(tool)
338338
}
339339

340340
/// Set all subagent tools for a specific Task (used when updating from agent file)
341-
mutating func setSubagentTools(_ tools: [SubagentToolCall], for taskID: String) {
341+
nonisolated mutating func setSubagentTools(_ tools: [SubagentToolCall], for taskID: String) {
342342
self.activeTasks[taskID]?.subagentTools = tools
343343
}
344344

345345
/// Add a subagent tool to the most recent active Task
346-
mutating func addSubagentTool(_ tool: SubagentToolCall) {
346+
nonisolated mutating func addSubagentTool(_ tool: SubagentToolCall) {
347347
// Find most recent active task (for parallel Task support)
348348
guard let mostRecentTaskID = activeTasks.keys.max(by: {
349349
(activeTasks[$0]?.startTime ?? .distantPast) < (activeTasks[$1]?.startTime ?? .distantPast)
@@ -354,7 +354,7 @@ struct SubagentState: Equatable, Sendable {
354354
}
355355

356356
/// Update the status of a subagent tool across all active Tasks
357-
mutating func updateSubagentToolStatus(toolID: String, status: ToolStatus) {
357+
nonisolated mutating func updateSubagentToolStatus(toolID: String, status: ToolStatus) {
358358
for taskID in self.activeTasks.keys {
359359
if let index = activeTasks[taskID]?.subagentTools.firstIndex(where: { $0.id == toolID }) {
360360
self.activeTasks[taskID]?.subagentTools[index].status = status

ClaudeIsland/Models/ToolResultData.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,9 +247,10 @@ struct ExitPlanModeResult: Equatable, Sendable {
247247
struct MCPResult: Equatable, @unchecked Sendable {
248248
let serverName: String
249249
let toolName: String
250-
let rawResult: [String: Any]
250+
/// nonisolated(unsafe) because [String: Any] isn't Sendable but we need cross-context access
251+
nonisolated(unsafe) let rawResult: [String: Any]
251252

252-
static func == (lhs: Self, rhs: Self) -> Bool {
253+
nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
253254
lhs.serverName == rhs.serverName &&
254255
lhs.toolName == rhs.toolName &&
255256
NSDictionary(dictionary: lhs.rawResult).isEqual(to: rhs.rawResult)
@@ -260,9 +261,10 @@ struct MCPResult: Equatable, @unchecked Sendable {
260261

261262
struct GenericResult: Equatable, @unchecked Sendable {
262263
let rawContent: String?
263-
let rawData: [String: Any]?
264+
/// nonisolated(unsafe) because [String: Any] isn't Sendable but we need cross-context access
265+
nonisolated(unsafe) let rawData: [String: Any]?
264266

265-
static func == (lhs: Self, rhs: Self) -> Bool {
267+
nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
266268
lhs.rawContent == rhs.rawContent
267269
}
268270
}

ClaudeIsland/Services/Hooks/HookInstaller.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,11 +228,11 @@ enum HookInstaller {
228228

229229
var updated = false
230230
for i in existingEvent.indices {
231-
if var entry = existingEvent[i] as? [String: Any],
232-
var entryHooks = entry["hooks"] as? [[String: Any]] {
231+
var entry = existingEvent[i]
232+
if var entryHooks = entry["hooks"] as? [[String: Any]] {
233233
for j in entryHooks.indices {
234-
if var hook = entryHooks[j] as? [String: Any],
235-
let cmd = hook["command"] as? String,
234+
var hook = entryHooks[j]
235+
if let cmd = hook["command"] as? String,
236236
cmd.contains("claude-island-state.py") {
237237
hook["command"] = command
238238
entryHooks[j] = hook

0 commit comments

Comments
 (0)