From 1eaa7756decaff755d5a814b1556d562b4051c1d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 22:27:25 +0000 Subject: [PATCH 01/13] Modernize codebase with Swift concurrency and Combine - Replace Timer.scheduledTimer with Combine Timer.publish - Replace NotificationCenter observer with Combine publisher - Add @MainActor annotations for thread safety - Add Sendable conformance to state types for thread safety - Use @main attribute instead of main.swift entry point - Encapsulate side effects in proper classes instead of global functions - Replace force unwrapping with safer optional binding - Extract magic numbers to named constants (DisplayConstants) - Use assertionFailure instead of fatalError for abstract methods - Improve code organization with smaller, focused methods https://claude.ai/code/session_01LsaxVzrkjDJMK8PYpthKvX --- DeskPad.xcodeproj/project.pbxproj | 4 - DeskPad/AppDelegate.swift | 3 +- DeskPad/Backend/AppState.swift | 4 +- .../MouseLocationSideEffect.swift | 64 ++++-- .../MouseLocation/MouseLocationState.swift | 4 +- .../ScreenConfigurationSideEffect.swift | 59 ++++-- .../ScreenConfigurationState.swift | 4 +- .../Screen/ScreenViewController.swift | 188 +++++++++++------- DeskPad/Frontend/Screen/ScreenViewData.swift | 6 +- DeskPad/Helpers/NSScreen+Extensions.swift | 6 +- DeskPad/SubscriberViewController.swift | 10 +- DeskPad/main.swift | 7 - 12 files changed, 228 insertions(+), 131 deletions(-) delete mode 100644 DeskPad/main.swift diff --git a/DeskPad.xcodeproj/project.pbxproj b/DeskPad.xcodeproj/project.pbxproj index 98777f5..810dfe3 100644 --- a/DeskPad.xcodeproj/project.pbxproj +++ b/DeskPad.xcodeproj/project.pbxproj @@ -21,7 +21,6 @@ 6D68E1B4287ABFC800CD574A /* ScreenConfigurationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D68E1B3287ABFC800CD574A /* ScreenConfigurationState.swift */; }; 6DC044522801877F00281728 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC044512801877F00281728 /* AppDelegate.swift */; }; 6DC044562801878100281728 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6DC044552801878100281728 /* Assets.xcassets */; }; - 6DC04461280191EB00281728 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC04460280191EB00281728 /* main.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -42,7 +41,6 @@ 6DC044512801877F00281728 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 6DC044552801878100281728 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 6DC0445A2801878100281728 /* DeskPad.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DeskPad.entitlements; sourceTree = ""; }; - 6DC04460280191EB00281728 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -131,7 +129,6 @@ 6DC044502801877F00281728 /* DeskPad */ = { isa = PBXGroup; children = ( - 6DC04460280191EB00281728 /* main.swift */, 6D2F1485280C20C800A3A2E5 /* SubscriberViewController.swift */, 6DC044512801877F00281728 /* AppDelegate.swift */, 6D2F1483280C201B00A3A2E5 /* Backend */, @@ -254,7 +251,6 @@ 6D41B0A42879FBA8007CEB2F /* ScreenViewData.swift in Sources */, 6D2F148E280C211E00A3A2E5 /* ScreenViewController.swift in Sources */, 6DC044522801877F00281728 /* AppDelegate.swift in Sources */, - 6DC04461280191EB00281728 /* main.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DeskPad/AppDelegate.swift b/DeskPad/AppDelegate.swift index 8e044cc..17a5730 100644 --- a/DeskPad/AppDelegate.swift +++ b/DeskPad/AppDelegate.swift @@ -5,7 +5,8 @@ enum AppDelegateAction: Action { case didFinishLaunching } -class AppDelegate: NSObject, NSApplicationDelegate { +@main +final class AppDelegate: NSObject, NSApplicationDelegate { var window: NSWindow! func applicationDidFinishLaunching(_: Notification) { diff --git a/DeskPad/Backend/AppState.swift b/DeskPad/Backend/AppState.swift index 16c59db..84f5914 100644 --- a/DeskPad/Backend/AppState.swift +++ b/DeskPad/Backend/AppState.swift @@ -1,11 +1,11 @@ import ReSwift -struct AppState: Equatable { +struct AppState: Equatable, Sendable { let mouseLocationState: MouseLocationState let screenConfigurationState: ScreenConfigurationState static var initialState: AppState { - return AppState( + AppState( mouseLocationState: .initialState, screenConfigurationState: .initialState ) diff --git a/DeskPad/Backend/MouseLocation/MouseLocationSideEffect.swift b/DeskPad/Backend/MouseLocation/MouseLocationSideEffect.swift index 74badbc..2f85fb9 100644 --- a/DeskPad/Backend/MouseLocation/MouseLocationSideEffect.swift +++ b/DeskPad/Backend/MouseLocation/MouseLocationSideEffect.swift @@ -1,32 +1,60 @@ +import Combine import Foundation import ReSwift -private var timer: Timer? - enum MouseLocationAction: Action { case located(isWithinScreen: Bool) case requestMove(toPoint: NSPoint) } -func mouseLocationSideEffect() -> SideEffect { - return { action, dispatch, getState in - if timer == nil { - timer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in - let mouseLocation = NSEvent.mouseLocation - let screens = NSScreen.screens - let screenContainingMouse = (screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }) - let isWithinScreen = screenContainingMouse?.displayID == getState()?.screenConfigurationState.displayID - dispatch(MouseLocationAction.located(isWithinScreen: isWithinScreen)) +@MainActor +final class MouseLocationSideEffect { + private var timerCancellable: AnyCancellable? + private var getState: (() -> AppState?)? + private var dispatch: DispatchFunction? + + nonisolated init() {} + + func start(dispatch: @escaping DispatchFunction, getState: @escaping () -> AppState?) { + self.dispatch = dispatch + self.getState = getState + + guard timerCancellable == nil else { return } + + timerCancellable = Timer.publish(every: 0.25, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.checkMouseLocation() } + } + + func handleAction(_ action: Action) { + guard case let MouseLocationAction.requestMove(point) = action else { + return } - switch action { - case let MouseLocationAction.requestMove(point): - guard let displayID = getState()?.screenConfigurationState.displayID else { - return - } - CGDisplayMoveCursorToPoint(displayID, point) - default: + + guard let displayID = getState?()?.screenConfigurationState.displayID else { return } + + CGDisplayMoveCursorToPoint(displayID, point) + } + + private func checkMouseLocation() { + let mouseLocation = NSEvent.mouseLocation + let screens = NSScreen.screens + let screenContainingMouse = screens.first { NSMouseInRect(mouseLocation, $0.frame, false) } + let isWithinScreen = screenContainingMouse?.displayID == getState?()?.screenConfigurationState.displayID + dispatch?(MouseLocationAction.located(isWithinScreen: isWithinScreen)) + } +} + +func mouseLocationSideEffect() -> SideEffect { + let handler = MouseLocationSideEffect() + return { action, dispatch, getState in + Task { @MainActor in + handler.start(dispatch: dispatch, getState: getState) + handler.handleAction(action) + } } } diff --git a/DeskPad/Backend/MouseLocation/MouseLocationState.swift b/DeskPad/Backend/MouseLocation/MouseLocationState.swift index dcc96b3..a15f0e5 100644 --- a/DeskPad/Backend/MouseLocation/MouseLocationState.swift +++ b/DeskPad/Backend/MouseLocation/MouseLocationState.swift @@ -1,11 +1,11 @@ import Foundation import ReSwift -struct MouseLocationState: Equatable { +struct MouseLocationState: Equatable, Sendable { let isWithinScreen: Bool static var initialState: MouseLocationState { - return MouseLocationState(isWithinScreen: false) + MouseLocationState(isWithinScreen: false) } } diff --git a/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift b/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift index 4c3c514..9ff6034 100644 --- a/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift +++ b/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift @@ -1,31 +1,52 @@ +import Combine import Foundation import ReSwift -private var isObserving = false - enum ScreenConfigurationAction: Action { case set(resolution: CGSize, scaleFactor: CGFloat) } +@MainActor +final class ScreenConfigurationSideEffect { + private var notificationCancellable: AnyCancellable? + private var getState: (() -> AppState?)? + private var dispatch: DispatchFunction? + + nonisolated init() {} + + func start(dispatch: @escaping DispatchFunction, getState: @escaping () -> AppState?) { + self.dispatch = dispatch + self.getState = getState + + guard notificationCancellable == nil else { return } + + notificationCancellable = NotificationCenter.default + .publisher(for: NSApplication.didChangeScreenParametersNotification, object: NSApplication.shared) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleScreenParametersChange() + } + } + + private func handleScreenParametersChange() { + guard let screen = NSScreen.screens.first(where: { + $0.displayID == getState?()?.screenConfigurationState.displayID + }) else { + return + } + + dispatch?(ScreenConfigurationAction.set( + resolution: screen.frame.size, + scaleFactor: screen.backingScaleFactor + )) + } +} + func screenConfigurationSideEffect() -> SideEffect { + let handler = ScreenConfigurationSideEffect() return { _, dispatch, getState in - if isObserving == false { - isObserving = true - NotificationCenter.default.addObserver( - forName: NSApplication.didChangeScreenParametersNotification, - object: NSApplication.shared, - queue: .main - ) { _ in - guard let screen = NSScreen.screens.first(where: { - $0.displayID == getState()?.screenConfigurationState.displayID - }) else { - return - } - dispatch(ScreenConfigurationAction.set( - resolution: screen.frame.size, - scaleFactor: screen.backingScaleFactor - )) - } + Task { @MainActor in + handler.start(dispatch: dispatch, getState: getState) } } } diff --git a/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationState.swift b/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationState.swift index 0b4ca0b..dea8527 100644 --- a/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationState.swift +++ b/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationState.swift @@ -1,13 +1,13 @@ import Foundation import ReSwift -struct ScreenConfigurationState: Equatable { +struct ScreenConfigurationState: Equatable, Sendable { let resolution: CGSize let scaleFactor: CGFloat let displayID: CGDirectDisplayID? static var initialState: ScreenConfigurationState { - return ScreenConfigurationState( + ScreenConfigurationState( resolution: .zero, scaleFactor: 1, displayID: nil diff --git a/DeskPad/Frontend/Screen/ScreenViewController.swift b/DeskPad/Frontend/Screen/ScreenViewController.swift index b47186c..14375dc 100644 --- a/DeskPad/Frontend/Screen/ScreenViewController.swift +++ b/DeskPad/Frontend/Screen/ScreenViewController.swift @@ -5,7 +5,41 @@ enum ScreenViewAction: Action { case setDisplayID(CGDirectDisplayID) } -class ScreenViewController: SubscriberViewController, NSWindowDelegate { +private enum DisplayConstants { + static let maxPixelsWide: UInt32 = 3840 + static let maxPixelsHigh: UInt32 = 2160 + static let sizeInMillimeters = CGSize(width: 1600, height: 1000) + static let productID: UInt32 = 0x1234 + static let vendorID: UInt32 = 0x3456 + static let serialNum: UInt32 = 0x0001 + static let refreshRate: CGFloat = 60 + static let windowSnappingOffset: CGFloat = 30 + static let minContentSize = CGSize(width: 400, height: 300) + + /// BGRA pixel format - 'BGRA' as 32-bit integer + static let bgraPixelFormat: Int32 = 1_111_970_369 +} + +private enum DisplayModes { + static let modes: [CGVirtualDisplayMode] = [ + // 16:9 aspect ratio + CGVirtualDisplayMode(width: 3840, height: 2160, refreshRate: DisplayConstants.refreshRate), + CGVirtualDisplayMode(width: 2560, height: 1440, refreshRate: DisplayConstants.refreshRate), + CGVirtualDisplayMode(width: 1920, height: 1080, refreshRate: DisplayConstants.refreshRate), + CGVirtualDisplayMode(width: 1600, height: 900, refreshRate: DisplayConstants.refreshRate), + CGVirtualDisplayMode(width: 1366, height: 768, refreshRate: DisplayConstants.refreshRate), + CGVirtualDisplayMode(width: 1280, height: 720, refreshRate: DisplayConstants.refreshRate), + // 16:10 aspect ratio + CGVirtualDisplayMode(width: 2560, height: 1600, refreshRate: DisplayConstants.refreshRate), + CGVirtualDisplayMode(width: 1920, height: 1200, refreshRate: DisplayConstants.refreshRate), + CGVirtualDisplayMode(width: 1680, height: 1050, refreshRate: DisplayConstants.refreshRate), + CGVirtualDisplayMode(width: 1440, height: 900, refreshRate: DisplayConstants.refreshRate), + CGVirtualDisplayMode(width: 1280, height: 800, refreshRate: DisplayConstants.refreshRate), + ] +} + +@MainActor +final class ScreenViewController: SubscriberViewController, NSWindowDelegate { override func loadView() { view = NSView() view.wantsLayer = true @@ -20,16 +54,19 @@ class ScreenViewController: SubscriberViewController, NSWindowDe override func viewDidLoad() { super.viewDidLoad() + configureVirtualDisplay() + } + private func configureVirtualDisplay() { let descriptor = CGVirtualDisplayDescriptor() descriptor.setDispatchQueue(DispatchQueue.main) descriptor.name = "DeskPad Display" - descriptor.maxPixelsWide = 3840 - descriptor.maxPixelsHigh = 2160 - descriptor.sizeInMillimeters = CGSize(width: 1600, height: 1000) - descriptor.productID = 0x1234 - descriptor.vendorID = 0x3456 - descriptor.serialNum = 0x0001 + descriptor.maxPixelsWide = DisplayConstants.maxPixelsWide + descriptor.maxPixelsHigh = DisplayConstants.maxPixelsHigh + descriptor.sizeInMillimeters = DisplayConstants.sizeInMillimeters + descriptor.productID = DisplayConstants.productID + descriptor.vendorID = DisplayConstants.vendorID + descriptor.serialNum = DisplayConstants.serialNum let display = CGVirtualDisplay(descriptor: descriptor) store.dispatch(ScreenViewAction.setDisplayID(display.displayID)) @@ -37,87 +74,102 @@ class ScreenViewController: SubscriberViewController, NSWindowDe let settings = CGVirtualDisplaySettings() settings.hiDPI = 1 - settings.modes = [ - // 16:9 - CGVirtualDisplayMode(width: 3840, height: 2160, refreshRate: 60), - CGVirtualDisplayMode(width: 2560, height: 1440, refreshRate: 60), - CGVirtualDisplayMode(width: 1920, height: 1080, refreshRate: 60), - CGVirtualDisplayMode(width: 1600, height: 900, refreshRate: 60), - CGVirtualDisplayMode(width: 1366, height: 768, refreshRate: 60), - CGVirtualDisplayMode(width: 1280, height: 720, refreshRate: 60), - // 16:10 - CGVirtualDisplayMode(width: 2560, height: 1600, refreshRate: 60), - CGVirtualDisplayMode(width: 1920, height: 1200, refreshRate: 60), - CGVirtualDisplayMode(width: 1680, height: 1050, refreshRate: 60), - CGVirtualDisplayMode(width: 1440, height: 900, refreshRate: 60), - CGVirtualDisplayMode(width: 1280, height: 800, refreshRate: 60), - ] + settings.modes = DisplayModes.modes display.apply(settings) } override func update(with viewData: ScreenViewData) { - if viewData.isWindowHighlighted != isWindowHighlighted { - isWindowHighlighted = viewData.isWindowHighlighted - view.window?.backgroundColor = isWindowHighlighted - ? NSColor(named: "TitleBarActive") - : NSColor(named: "TitleBarInactive") - if isWindowHighlighted { - view.window?.orderFrontRegardless() - } + updateWindowHighlight(isHighlighted: viewData.isWindowHighlighted) + updateDisplayStream(resolution: viewData.resolution, scaleFactor: viewData.scaleFactor) + } + + private func updateWindowHighlight(isHighlighted: Bool) { + guard isHighlighted != isWindowHighlighted else { return } + + isWindowHighlighted = isHighlighted + view.window?.backgroundColor = isHighlighted + ? NSColor(named: "TitleBarActive") + : NSColor(named: "TitleBarInactive") + + if isHighlighted { + view.window?.orderFrontRegardless() + } + } + + private func updateDisplayStream(resolution: CGSize, scaleFactor: CGFloat) { + guard resolution != .zero, + resolution != previousResolution || scaleFactor != previousScaleFactor + else { + return } - if - viewData.resolution != .zero, - viewData.resolution != previousResolution - || viewData.scaleFactor != previousScaleFactor - { - previousResolution = viewData.resolution - previousScaleFactor = viewData.scaleFactor - stream = nil - view.window?.setContentSize(viewData.resolution) - view.window?.contentAspectRatio = viewData.resolution - view.window?.center() - let stream = CGDisplayStream( - dispatchQueueDisplay: display.displayID, - outputWidth: Int(viewData.resolution.width * viewData.scaleFactor), - outputHeight: Int(viewData.resolution.height * viewData.scaleFactor), - pixelFormat: 1_111_970_369, - properties: [ - CGDisplayStream.showCursor: true, - ] as CFDictionary, - queue: .main, - handler: { [weak self] _, _, frameSurface, _ in - if let surface = frameSurface { - self?.view.layer?.contents = surface - } - } - ) - self.stream = stream - stream?.start() + previousResolution = resolution + previousScaleFactor = scaleFactor + stream = nil + + configureWindowSize(for: resolution) + createDisplayStream(resolution: resolution, scaleFactor: scaleFactor) + } + + private func configureWindowSize(for resolution: CGSize) { + view.window?.setContentSize(resolution) + view.window?.contentAspectRatio = resolution + view.window?.center() + } + + private func createDisplayStream(resolution: CGSize, scaleFactor: CGFloat) { + let outputWidth = Int(resolution.width * scaleFactor) + let outputHeight = Int(resolution.height * scaleFactor) + + let stream = CGDisplayStream( + dispatchQueueDisplay: display.displayID, + outputWidth: outputWidth, + outputHeight: outputHeight, + pixelFormat: DisplayConstants.bgraPixelFormat, + properties: [ + CGDisplayStream.showCursor: true, + ] as CFDictionary, + queue: .main, + handler: { [weak self] _, _, frameSurface, _ in + guard let surface = frameSurface else { return } + self?.view.layer?.contents = surface + } + ) + + self.stream = stream + stream?.start() + } + + nonisolated func windowWillResize(_ window: NSWindow, to frameSize: NSSize) -> NSSize { + MainActor.assumeIsolated { + calculateResizedFrame(for: window, proposedSize: frameSize) } } - func windowWillResize(_ window: NSWindow, to frameSize: NSSize) -> NSSize { - let snappingOffset: CGFloat = 30 + private func calculateResizedFrame(for window: NSWindow, proposedSize frameSize: NSSize) -> NSSize { let contentSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: frameSize)).size - guard - let screenResolution = previousResolution, - abs(contentSize.width - screenResolution.width) < snappingOffset + + guard let screenResolution = previousResolution, + abs(contentSize.width - screenResolution.width) < DisplayConstants.windowSnappingOffset else { return frameSize } + return window.frameRect(forContentRect: NSRect(origin: .zero, size: screenResolution)).size } @objc private func didClickOnScreen(_ gestureRecognizer: NSGestureRecognizer) { - guard let screenResolution = previousResolution else { - return - } + guard let screenResolution = previousResolution else { return } + let clickedPoint = gestureRecognizer.location(in: view) + let normalizedX = clickedPoint.x / view.frame.width + let normalizedY = (view.frame.height - clickedPoint.y) / view.frame.height + let onScreenPoint = NSPoint( - x: clickedPoint.x / view.frame.width * screenResolution.width, - y: (view.frame.height - clickedPoint.y) / view.frame.height * screenResolution.height + x: normalizedX * screenResolution.width, + y: normalizedY * screenResolution.height ) + store.dispatch(MouseLocationAction.requestMove(toPoint: onScreenPoint)) } } diff --git a/DeskPad/Frontend/Screen/ScreenViewData.swift b/DeskPad/Frontend/Screen/ScreenViewData.swift index f39de1b..d63985c 100644 --- a/DeskPad/Frontend/Screen/ScreenViewData.swift +++ b/DeskPad/Frontend/Screen/ScreenViewData.swift @@ -1,13 +1,13 @@ import Foundation -struct ScreenViewData: ViewDataType { - struct StateFragment: Equatable { +struct ScreenViewData: ViewDataType, Sendable { + struct StateFragment: Equatable, Sendable { let mouseLocationState: MouseLocationState let screenConfiguration: ScreenConfigurationState } static func fragment(of appState: AppState) -> StateFragment { - return StateFragment( + StateFragment( mouseLocationState: appState.mouseLocationState, screenConfiguration: appState.screenConfigurationState ) diff --git a/DeskPad/Helpers/NSScreen+Extensions.swift b/DeskPad/Helpers/NSScreen+Extensions.swift index 86d3105..e0b1470 100644 --- a/DeskPad/Helpers/NSScreen+Extensions.swift +++ b/DeskPad/Helpers/NSScreen+Extensions.swift @@ -2,6 +2,10 @@ import Foundation extension NSScreen { var displayID: CGDirectDisplayID { - return deviceDescription[NSDeviceDescriptionKey(rawValue: "NSScreenNumber")] as! CGDirectDisplayID + guard let screenNumber = deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + assertionFailure("Failed to get display ID from NSScreen") + return 0 + } + return CGDirectDisplayID(screenNumber.uint32Value) } } diff --git a/DeskPad/SubscriberViewController.swift b/DeskPad/SubscriberViewController.swift index e34bbdc..11fb9a5 100644 --- a/DeskPad/SubscriberViewController.swift +++ b/DeskPad/SubscriberViewController.swift @@ -1,6 +1,7 @@ import AppKit import ReSwift +@MainActor class SubscriberViewController: NSViewController, StoreSubscriber { typealias StoreSubscriberStateType = ViewData.StateFragment @@ -20,14 +21,15 @@ class SubscriberViewController: NSViewController, StoreS store.unsubscribe(self) } - func newState(state: ViewData.StateFragment) { - DispatchQueue.main.async { [weak self] in - self?.update(with: ViewData(for: state)) + nonisolated func newState(state: ViewData.StateFragment) { + Task { @MainActor [weak self] in + guard let self else { return } + self.update(with: ViewData(for: state)) } } func update(with _: ViewData) { - fatalError("Please override the SubscriberViewController update method.") + assertionFailure("Please override the SubscriberViewController update method.") } } diff --git a/DeskPad/main.swift b/DeskPad/main.swift deleted file mode 100644 index 5d2c0ec..0000000 --- a/DeskPad/main.swift +++ /dev/null @@ -1,7 +0,0 @@ -import AppKit - -let app = NSApplication.shared -let delegate = AppDelegate() -app.delegate = delegate - -_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) From f81423cc69127012d0e404cac7cf8209337b00c6 Mon Sep 17 00:00:00 2001 From: xocialize Date: Mon, 26 Jan 2026 14:33:45 -0800 Subject: [PATCH 02/13] Certs --- .DS_Store | Bin 0 -> 6148 bytes DeskPad.xcodeproj/project.pbxproj | 8 ++++---- 2 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..87a203cb0dc96017081fb9e656e336284f24169e GIT binary patch literal 6148 zcmeHKOG*Pl5PhXt47f>lF8cr?1U$eP#=VdWm}G*Y2{RZ1E@vgU_fER?2I9hV_^PW7 z8JxweM5GG3Url$t{ydoOE&wvwmSbQDAY)M!bvjI&JG%~2@PH_Bj7!W=;uH^9bPV(t zn{@3X6j)+|JM6E&;!c*RagPeOtLyP}QLSs+!qZzn*19T+`Kp>TI{P^LoQtUG3LM(!Q1AZ>{xY3Oew*T7u7E4>&lHeBF)b$CRJvP#Y)|jnz;eZ+rg4or nH2SSa06+8`IgXvyAJk`Dp0F|0QM5hMiT)6Xf_UW${DJ~sZst7H literal 0 HcmV?d00001 diff --git a/DeskPad.xcodeproj/project.pbxproj b/DeskPad.xcodeproj/project.pbxproj index 810dfe3..d119a9e 100644 --- a/DeskPad.xcodeproj/project.pbxproj +++ b/DeskPad.xcodeproj/project.pbxproj @@ -383,7 +383,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 6; - DEVELOPMENT_TEAM = TYPC962S4N; + DEVELOPMENT_TEAM = F84FKGN358; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -393,7 +393,7 @@ "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.3.2; - PRODUCT_BUNDLE_IDENTIFIER = com.stengo.DeskPad; + PRODUCT_BUNDLE_IDENTIFIER = "com.mvstaging.desk-pad"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -409,7 +409,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 6; - DEVELOPMENT_TEAM = TYPC962S4N; + DEVELOPMENT_TEAM = F84FKGN358; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -419,7 +419,7 @@ "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.3.2; - PRODUCT_BUNDLE_IDENTIFIER = com.stengo.DeskPad; + PRODUCT_BUNDLE_IDENTIFIER = "com.mvstaging.desk-pad"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; From 08b442ecb5c00b6000fa68b134ebf917d5541061 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 22:36:59 +0000 Subject: [PATCH 03/13] Fix window not appearing due to async side effect initialization The Task { @MainActor } wrapper was causing side effects to initialize asynchronously, which meant the notification observer wasn't set up before the screen parameters notification fired from the virtual display. Changes: - Remove @MainActor class wrappers from side effects, use module-level state with Combine publishers that handle main thread scheduling - Remove @MainActor from SubscriberViewController, use DispatchQueue.main.async - Remove @MainActor from ScreenViewController - Simplify windowWillResize delegate method The Combine Timer.publish and NotificationCenter.publisher still ensure callbacks run on the main thread, but the observer setup is now synchronous. https://claude.ai/code/session_01LsaxVzrkjDJMK8PYpthKvX --- .../MouseLocationSideEffect.swift | 64 ++++++------------- .../ScreenConfigurationSideEffect.swift | 56 ++++++---------- .../Screen/ScreenViewController.swift | 7 +- DeskPad/SubscriberViewController.swift | 7 +- 4 files changed, 43 insertions(+), 91 deletions(-) diff --git a/DeskPad/Backend/MouseLocation/MouseLocationSideEffect.swift b/DeskPad/Backend/MouseLocation/MouseLocationSideEffect.swift index 2f85fb9..d40197a 100644 --- a/DeskPad/Backend/MouseLocation/MouseLocationSideEffect.swift +++ b/DeskPad/Backend/MouseLocation/MouseLocationSideEffect.swift @@ -7,54 +7,28 @@ enum MouseLocationAction: Action { case requestMove(toPoint: NSPoint) } -@MainActor -final class MouseLocationSideEffect { - private var timerCancellable: AnyCancellable? - private var getState: (() -> AppState?)? - private var dispatch: DispatchFunction? - - nonisolated init() {} - - func start(dispatch: @escaping DispatchFunction, getState: @escaping () -> AppState?) { - self.dispatch = dispatch - self.getState = getState - - guard timerCancellable == nil else { return } - - timerCancellable = Timer.publish(every: 0.25, on: .main, in: .common) - .autoconnect() - .sink { [weak self] _ in - self?.checkMouseLocation() - } - } - - func handleAction(_ action: Action) { - guard case let MouseLocationAction.requestMove(point) = action else { - return - } - - guard let displayID = getState?()?.screenConfigurationState.displayID else { - return - } - - CGDisplayMoveCursorToPoint(displayID, point) - } - - private func checkMouseLocation() { - let mouseLocation = NSEvent.mouseLocation - let screens = NSScreen.screens - let screenContainingMouse = screens.first { NSMouseInRect(mouseLocation, $0.frame, false) } - let isWithinScreen = screenContainingMouse?.displayID == getState?()?.screenConfigurationState.displayID - dispatch?(MouseLocationAction.located(isWithinScreen: isWithinScreen)) - } -} +private var timerCancellable: AnyCancellable? func mouseLocationSideEffect() -> SideEffect { - let handler = MouseLocationSideEffect() return { action, dispatch, getState in - Task { @MainActor in - handler.start(dispatch: dispatch, getState: getState) - handler.handleAction(action) + // Set up timer on first action (runs on main thread via Combine) + if timerCancellable == nil { + timerCancellable = Timer.publish(every: 0.25, on: .main, in: .common) + .autoconnect() + .sink { _ in + let mouseLocation = NSEvent.mouseLocation + let screens = NSScreen.screens + let screenContainingMouse = screens.first { NSMouseInRect(mouseLocation, $0.frame, false) } + let isWithinScreen = screenContainingMouse?.displayID == getState()?.screenConfigurationState.displayID + dispatch(MouseLocationAction.located(isWithinScreen: isWithinScreen)) + } + } + + // Handle move cursor action + if case let MouseLocationAction.requestMove(point) = action, + let displayID = getState()?.screenConfigurationState.displayID + { + CGDisplayMoveCursorToPoint(displayID, point) } } } diff --git a/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift b/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift index 9ff6034..62ff0ab 100644 --- a/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift +++ b/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift @@ -6,47 +6,27 @@ enum ScreenConfigurationAction: Action { case set(resolution: CGSize, scaleFactor: CGFloat) } -@MainActor -final class ScreenConfigurationSideEffect { - private var notificationCancellable: AnyCancellable? - private var getState: (() -> AppState?)? - private var dispatch: DispatchFunction? - - nonisolated init() {} - - func start(dispatch: @escaping DispatchFunction, getState: @escaping () -> AppState?) { - self.dispatch = dispatch - self.getState = getState - - guard notificationCancellable == nil else { return } - - notificationCancellable = NotificationCenter.default - .publisher(for: NSApplication.didChangeScreenParametersNotification, object: NSApplication.shared) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.handleScreenParametersChange() - } - } - - private func handleScreenParametersChange() { - guard let screen = NSScreen.screens.first(where: { - $0.displayID == getState?()?.screenConfigurationState.displayID - }) else { - return - } - - dispatch?(ScreenConfigurationAction.set( - resolution: screen.frame.size, - scaleFactor: screen.backingScaleFactor - )) - } -} +private var notificationCancellable: AnyCancellable? func screenConfigurationSideEffect() -> SideEffect { - let handler = ScreenConfigurationSideEffect() return { _, dispatch, getState in - Task { @MainActor in - handler.start(dispatch: dispatch, getState: getState) + // Set up notification observer on first action (runs on main thread via Combine) + if notificationCancellable == nil { + notificationCancellable = NotificationCenter.default + .publisher(for: NSApplication.didChangeScreenParametersNotification, object: NSApplication.shared) + .receive(on: DispatchQueue.main) + .sink { _ in + guard let screen = NSScreen.screens.first(where: { + $0.displayID == getState()?.screenConfigurationState.displayID + }) else { + return + } + + dispatch(ScreenConfigurationAction.set( + resolution: screen.frame.size, + scaleFactor: screen.backingScaleFactor + )) + } } } } diff --git a/DeskPad/Frontend/Screen/ScreenViewController.swift b/DeskPad/Frontend/Screen/ScreenViewController.swift index 14375dc..1adc41f 100644 --- a/DeskPad/Frontend/Screen/ScreenViewController.swift +++ b/DeskPad/Frontend/Screen/ScreenViewController.swift @@ -38,7 +38,6 @@ private enum DisplayModes { ] } -@MainActor final class ScreenViewController: SubscriberViewController, NSWindowDelegate { override func loadView() { view = NSView() @@ -140,10 +139,8 @@ final class ScreenViewController: SubscriberViewController, NSWi stream?.start() } - nonisolated func windowWillResize(_ window: NSWindow, to frameSize: NSSize) -> NSSize { - MainActor.assumeIsolated { - calculateResizedFrame(for: window, proposedSize: frameSize) - } + func windowWillResize(_ window: NSWindow, to frameSize: NSSize) -> NSSize { + calculateResizedFrame(for: window, proposedSize: frameSize) } private func calculateResizedFrame(for window: NSWindow, proposedSize frameSize: NSSize) -> NSSize { diff --git a/DeskPad/SubscriberViewController.swift b/DeskPad/SubscriberViewController.swift index 11fb9a5..9dfa864 100644 --- a/DeskPad/SubscriberViewController.swift +++ b/DeskPad/SubscriberViewController.swift @@ -1,7 +1,6 @@ import AppKit import ReSwift -@MainActor class SubscriberViewController: NSViewController, StoreSubscriber { typealias StoreSubscriberStateType = ViewData.StateFragment @@ -21,8 +20,10 @@ class SubscriberViewController: NSViewController, StoreS store.unsubscribe(self) } - nonisolated func newState(state: ViewData.StateFragment) { - Task { @MainActor [weak self] in + func newState(state: ViewData.StateFragment) { + // ReSwift calls this on the same thread as dispatch (main thread in this app) + // Use async to avoid potential re-entrancy issues during dispatch + DispatchQueue.main.async { [weak self] in guard let self else { return } self.update(with: ViewData(for: state)) } From cbcdd3c647b0171ca72edb10861b1a80fc071d12 Mon Sep 17 00:00:00 2001 From: xocialize Date: Mon, 26 Jan 2026 14:41:44 -0800 Subject: [PATCH 04/13] buggy --- .DS_Store | Bin 6148 -> 6148 bytes DeskPad.xcodeproj/project.pbxproj | 20 +++++++++++++++++--- DeskPad/DeskPad.entitlements | 4 ---- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.DS_Store b/.DS_Store index 87a203cb0dc96017081fb9e656e336284f24169e..70cd25d95f478b744bfc13a2677235b3055ba465 100644 GIT binary patch delta 51 zcmV-30L=e{FoZC$Kmh`dVv~FUD3fXd3I*y&;Bpd^c>y((bOA1t+yM>&@U!s&76y@k J2D1kU{SU}<5wrjR delta 66 zcmV-I0KNZ&FoZC$Kmh`Y(UW`uD3fXd3X`q@E);EVWJF?YZ(;xd00@T2EC>;l$3c?} Y0Vb2&0S*D+v+)5I29baUvj+(M57bB%p8x;= diff --git a/DeskPad.xcodeproj/project.pbxproj b/DeskPad.xcodeproj/project.pbxproj index d119a9e..62c2dd3 100644 --- a/DeskPad.xcodeproj/project.pbxproj +++ b/DeskPad.xcodeproj/project.pbxproj @@ -174,7 +174,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1320; - LastUpgradeCheck = 1320; + LastUpgradeCheck = 2620; TargetAttributes = { 6DC0444D2801877F00281728 = { CreatedOnToolsVersion = 13.2.1; @@ -261,6 +261,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -291,9 +292,12 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = F84FKGN358; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -313,6 +317,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OBJC_BRIDGING_HEADER = "DeskPad/DeskPad-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -323,6 +328,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -353,9 +359,12 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = F84FKGN358; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -368,6 +377,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OBJC_BRIDGING_HEADER = "DeskPad/DeskPad-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-O"; @@ -383,8 +393,10 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 6; - DEVELOPMENT_TEAM = F84FKGN358; + DEAD_CODE_STRIPPING = YES; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -409,8 +421,10 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 6; - DEVELOPMENT_TEAM = F84FKGN358; + DEAD_CODE_STRIPPING = YES; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; diff --git a/DeskPad/DeskPad.entitlements b/DeskPad/DeskPad.entitlements index 7f75e23..1407da8 100644 --- a/DeskPad/DeskPad.entitlements +++ b/DeskPad/DeskPad.entitlements @@ -2,10 +2,6 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - com.apple.security.temporary-exception.mach-lookup.global-name com.apple.VirtualDisplay From 529a36eb285a6d01e2b3baa369dfe38af4b85fa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 22:43:31 +0000 Subject: [PATCH 05/13] Disable user script sandboxing for SwiftFormat build phase ENABLE_USER_SCRIPT_SANDBOXING was enabled by Xcode's recommended settings, but this prevents the SwiftFormat build script from accessing project source files. https://claude.ai/code/session_01LsaxVzrkjDJMK8PYpthKvX --- DeskPad.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DeskPad.xcodeproj/project.pbxproj b/DeskPad.xcodeproj/project.pbxproj index 62c2dd3..9128ea0 100644 --- a/DeskPad.xcodeproj/project.pbxproj +++ b/DeskPad.xcodeproj/project.pbxproj @@ -297,7 +297,7 @@ DEVELOPMENT_TEAM = F84FKGN358; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -364,7 +364,7 @@ DEVELOPMENT_TEAM = F84FKGN358; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; From 8c975be57432192ce6a3e409dfcc7f0b4583aae9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 22:46:40 +0000 Subject: [PATCH 06/13] Fix initial screen configuration not being set The didChangeScreenParametersNotification may fire when CGVirtualDisplay is created, before the observer is set up. This caused the initial screen configuration to be missed. Now when setDisplayID is dispatched, we also query the screen configuration after a short async delay to allow the virtual display to fully initialize. This ensures the window gets its proper size on launch. https://claude.ai/code/session_01LsaxVzrkjDJMK8PYpthKvX --- .../ScreenConfigurationSideEffect.swift | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift b/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift index 62ff0ab..806a9f1 100644 --- a/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift +++ b/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift @@ -9,24 +9,37 @@ enum ScreenConfigurationAction: Action { private var notificationCancellable: AnyCancellable? func screenConfigurationSideEffect() -> SideEffect { - return { _, dispatch, getState in - // Set up notification observer on first action (runs on main thread via Combine) + return { action, dispatch, getState in + // Set up notification observer on first action if notificationCancellable == nil { notificationCancellable = NotificationCenter.default .publisher(for: NSApplication.didChangeScreenParametersNotification, object: NSApplication.shared) .receive(on: DispatchQueue.main) .sink { _ in - guard let screen = NSScreen.screens.first(where: { - $0.displayID == getState()?.screenConfigurationState.displayID - }) else { - return - } - - dispatch(ScreenConfigurationAction.set( - resolution: screen.frame.size, - scaleFactor: screen.backingScaleFactor - )) + updateScreenConfiguration(dispatch: dispatch, getState: getState) } } + + // When displayID is set, query the initial screen configuration + // This handles the case where the notification fired before the observer was set up + if case ScreenViewAction.setDisplayID = action { + // Use async to allow the virtual display to fully initialize + DispatchQueue.main.async { + updateScreenConfiguration(dispatch: dispatch, getState: getState) + } + } } } + +private func updateScreenConfiguration(dispatch: @escaping DispatchFunction, getState: @escaping () -> AppState?) { + guard let displayID = getState()?.screenConfigurationState.displayID, + let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) + else { + return + } + + dispatch(ScreenConfigurationAction.set( + resolution: screen.frame.size, + scaleFactor: screen.backingScaleFactor + )) +} From 99622698515eed7323a919db891c4e303e4c4b45 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 22:50:25 +0000 Subject: [PATCH 07/13] Revert side effects to original patterns and fix SwiftFormat warning The Combine-based side effects had subtle timing differences that prevented the virtual display from initializing properly. Reverted to the original Timer.scheduledTimer and NotificationCenter.addObserver patterns which work correctly. Also fixed the SwiftFormat build phase warning by setting alwaysOutOfDate to tell Xcode the script should run on every build (equivalent to unchecking "Based on dependency analysis" in the build phase settings). https://claude.ai/code/session_01LsaxVzrkjDJMK8PYpthKvX --- DeskPad.xcodeproj/project.pbxproj | 1 + .../MouseLocationSideEffect.swift | 32 ++++++------ .../ScreenConfigurationSideEffect.swift | 50 +++++++------------ 3 files changed, 33 insertions(+), 50 deletions(-) diff --git a/DeskPad.xcodeproj/project.pbxproj b/DeskPad.xcodeproj/project.pbxproj index 9128ea0..21ab738 100644 --- a/DeskPad.xcodeproj/project.pbxproj +++ b/DeskPad.xcodeproj/project.pbxproj @@ -216,6 +216,7 @@ /* Begin PBXShellScriptBuildPhase section */ 6D2F147F280C1C1400A3A2E5 /* Run SwiftFormat */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); diff --git a/DeskPad/Backend/MouseLocation/MouseLocationSideEffect.swift b/DeskPad/Backend/MouseLocation/MouseLocationSideEffect.swift index d40197a..5fe262f 100644 --- a/DeskPad/Backend/MouseLocation/MouseLocationSideEffect.swift +++ b/DeskPad/Backend/MouseLocation/MouseLocationSideEffect.swift @@ -1,33 +1,29 @@ -import Combine import Foundation import ReSwift +private var timer: Timer? + enum MouseLocationAction: Action { case located(isWithinScreen: Bool) case requestMove(toPoint: NSPoint) } -private var timerCancellable: AnyCancellable? - func mouseLocationSideEffect() -> SideEffect { return { action, dispatch, getState in - // Set up timer on first action (runs on main thread via Combine) - if timerCancellable == nil { - timerCancellable = Timer.publish(every: 0.25, on: .main, in: .common) - .autoconnect() - .sink { _ in - let mouseLocation = NSEvent.mouseLocation - let screens = NSScreen.screens - let screenContainingMouse = screens.first { NSMouseInRect(mouseLocation, $0.frame, false) } - let isWithinScreen = screenContainingMouse?.displayID == getState()?.screenConfigurationState.displayID - dispatch(MouseLocationAction.located(isWithinScreen: isWithinScreen)) - } + if timer == nil { + timer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in + let mouseLocation = NSEvent.mouseLocation + let screens = NSScreen.screens + let screenContainingMouse = screens.first { NSMouseInRect(mouseLocation, $0.frame, false) } + let isWithinScreen = screenContainingMouse?.displayID == getState()?.screenConfigurationState.displayID + dispatch(MouseLocationAction.located(isWithinScreen: isWithinScreen)) + } } - // Handle move cursor action - if case let MouseLocationAction.requestMove(point) = action, - let displayID = getState()?.screenConfigurationState.displayID - { + if case let MouseLocationAction.requestMove(point) = action { + guard let displayID = getState()?.screenConfigurationState.displayID else { + return + } CGDisplayMoveCursorToPoint(displayID, point) } } diff --git a/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift b/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift index 806a9f1..4c3c514 100644 --- a/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift +++ b/DeskPad/Backend/ScreenConfiguration/ScreenConfigurationSideEffect.swift @@ -1,45 +1,31 @@ -import Combine import Foundation import ReSwift +private var isObserving = false + enum ScreenConfigurationAction: Action { case set(resolution: CGSize, scaleFactor: CGFloat) } -private var notificationCancellable: AnyCancellable? - func screenConfigurationSideEffect() -> SideEffect { - return { action, dispatch, getState in - // Set up notification observer on first action - if notificationCancellable == nil { - notificationCancellable = NotificationCenter.default - .publisher(for: NSApplication.didChangeScreenParametersNotification, object: NSApplication.shared) - .receive(on: DispatchQueue.main) - .sink { _ in - updateScreenConfiguration(dispatch: dispatch, getState: getState) + return { _, dispatch, getState in + if isObserving == false { + isObserving = true + NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: NSApplication.shared, + queue: .main + ) { _ in + guard let screen = NSScreen.screens.first(where: { + $0.displayID == getState()?.screenConfigurationState.displayID + }) else { + return } - } - - // When displayID is set, query the initial screen configuration - // This handles the case where the notification fired before the observer was set up - if case ScreenViewAction.setDisplayID = action { - // Use async to allow the virtual display to fully initialize - DispatchQueue.main.async { - updateScreenConfiguration(dispatch: dispatch, getState: getState) + dispatch(ScreenConfigurationAction.set( + resolution: screen.frame.size, + scaleFactor: screen.backingScaleFactor + )) } } } } - -private func updateScreenConfiguration(dispatch: @escaping DispatchFunction, getState: @escaping () -> AppState?) { - guard let displayID = getState()?.screenConfigurationState.displayID, - let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) - else { - return - } - - dispatch(ScreenConfigurationAction.set( - resolution: screen.frame.size, - scaleFactor: screen.backingScaleFactor - )) -} From 0d2419722b8cb84585634379a9269b7f7791bd0d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 22:59:27 +0000 Subject: [PATCH 08/13] Rebuild app with modern architecture using Metal and Combine Complete rewrite of the application with a cleaner, more modern architecture: New Core module: - VirtualDisplayManager: Manages virtual display lifecycle with Combine - @Published properties for reactive state updates - Automatic retry mechanism for display detection - Clean initialization and cleanup - DisplayStreamRenderer: Metal-backed display streaming - CAMetalLayer for GPU-efficient rendering - Direct IOSurface assignment (zero-copy) - Coordinate conversion for click-to-move - MouseTracker: Mouse position tracking with Combine - @Published isWithinVirtualDisplay state - Timer-based polling with clean lifecycle - Cursor movement via CGDisplayMoveCursorToPoint - DeskPadViewController: Main view controller - Combine bindings for reactive updates - Dependency injection for testability - Clean separation of concerns Key improvements: - No more ReSwift dependency for core functionality - Combine for reactive state management - Metal layer for GPU-friendly rendering - Proper retry mechanism for display initialization - Clear ownership and lifecycle management https://claude.ai/code/session_01LsaxVzrkjDJMK8PYpthKvX --- DeskPad.xcodeproj/project.pbxproj | 24 ++++ DeskPad/AppDelegate.swift | 71 ++++++++--- DeskPad/Core/DeskPadViewController.swift | 155 +++++++++++++++++++++++ DeskPad/Core/DisplayStreamRenderer.swift | 149 ++++++++++++++++++++++ DeskPad/Core/MouseTracker.swift | 70 ++++++++++ DeskPad/Core/VirtualDisplayManager.swift | 135 ++++++++++++++++++++ 6 files changed, 584 insertions(+), 20 deletions(-) create mode 100644 DeskPad/Core/DeskPadViewController.swift create mode 100644 DeskPad/Core/DisplayStreamRenderer.swift create mode 100644 DeskPad/Core/MouseTracker.swift create mode 100644 DeskPad/Core/VirtualDisplayManager.swift diff --git a/DeskPad.xcodeproj/project.pbxproj b/DeskPad.xcodeproj/project.pbxproj index 21ab738..53b6982 100644 --- a/DeskPad.xcodeproj/project.pbxproj +++ b/DeskPad.xcodeproj/project.pbxproj @@ -13,6 +13,10 @@ 6D2F148B280C20D000A3A2E5 /* SideEffectsMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2F1488280C20D000A3A2E5 /* SideEffectsMiddleware.swift */; }; 6D2F148C280C20D000A3A2E5 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2F1489280C20D000A3A2E5 /* Store.swift */; }; 6D2F148E280C211E00A3A2E5 /* ScreenViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2F148D280C211E00A3A2E5 /* ScreenViewController.swift */; }; + CORE001280C211E00A3A2E5 /* VirtualDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CORE002280C211E00A3A2E5 /* VirtualDisplayManager.swift */; }; + CORE003280C211E00A3A2E5 /* DisplayStreamRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CORE004280C211E00A3A2E5 /* DisplayStreamRenderer.swift */; }; + CORE005280C211E00A3A2E5 /* MouseTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CORE006280C211E00A3A2E5 /* MouseTracker.swift */; }; + CORE007280C211E00A3A2E5 /* DeskPadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CORE008280C211E00A3A2E5 /* DeskPadViewController.swift */; }; 6D41B09F2879FAB6007CEB2F /* MouseLocationSideEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D41B09E2879FAB6007CEB2F /* MouseLocationSideEffect.swift */; }; 6D41B0A12879FABE007CEB2F /* MouseLocationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D41B0A02879FABE007CEB2F /* MouseLocationState.swift */; }; 6D41B0A42879FBA8007CEB2F /* ScreenViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D41B0A32879FBA8007CEB2F /* ScreenViewData.swift */; }; @@ -24,6 +28,10 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + CORE002280C211E00A3A2E5 /* VirtualDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualDisplayManager.swift; sourceTree = ""; }; + CORE004280C211E00A3A2E5 /* DisplayStreamRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayStreamRenderer.swift; sourceTree = ""; }; + CORE006280C211E00A3A2E5 /* MouseTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MouseTracker.swift; sourceTree = ""; }; + CORE008280C211E00A3A2E5 /* DeskPadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeskPadViewController.swift; sourceTree = ""; }; 6D2F1485280C20C800A3A2E5 /* SubscriberViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriberViewController.swift; sourceTree = ""; }; 6D2F1487280C20D000A3A2E5 /* AppState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 6D2F1488280C20D000A3A2E5 /* SideEffectsMiddleware.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SideEffectsMiddleware.swift; sourceTree = ""; }; @@ -110,6 +118,17 @@ path = Helpers; sourceTree = ""; }; + COREGRP280C211E00A3A2E5 /* Core */ = { + isa = PBXGroup; + children = ( + CORE002280C211E00A3A2E5 /* VirtualDisplayManager.swift */, + CORE004280C211E00A3A2E5 /* DisplayStreamRenderer.swift */, + CORE006280C211E00A3A2E5 /* MouseTracker.swift */, + CORE008280C211E00A3A2E5 /* DeskPadViewController.swift */, + ); + path = Core; + sourceTree = ""; + }; 6DC044452801877F00281728 = { isa = PBXGroup; children = ( @@ -129,6 +148,7 @@ 6DC044502801877F00281728 /* DeskPad */ = { isa = PBXGroup; children = ( + COREGRP280C211E00A3A2E5 /* Core */, 6D2F1485280C20C800A3A2E5 /* SubscriberViewController.swift */, 6DC044512801877F00281728 /* AppDelegate.swift */, 6D2F1483280C201B00A3A2E5 /* Backend */, @@ -240,6 +260,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CORE001280C211E00A3A2E5 /* VirtualDisplayManager.swift in Sources */, + CORE003280C211E00A3A2E5 /* DisplayStreamRenderer.swift in Sources */, + CORE005280C211E00A3A2E5 /* MouseTracker.swift in Sources */, + CORE007280C211E00A3A2E5 /* DeskPadViewController.swift in Sources */, 6D68E1B4287ABFC800CD574A /* ScreenConfigurationState.swift in Sources */, 6D68E1B2287ABDB900CD574A /* NSScreen+Extensions.swift in Sources */, 6D2F148C280C20D000A3A2E5 /* Store.swift in Sources */, diff --git a/DeskPad/AppDelegate.swift b/DeskPad/AppDelegate.swift index 17a5730..6fb4c59 100644 --- a/DeskPad/AppDelegate.swift +++ b/DeskPad/AppDelegate.swift @@ -1,20 +1,48 @@ import Cocoa -import ReSwift - -enum AppDelegateAction: Action { - case didFinishLaunching -} @main final class AppDelegate: NSObject, NSApplicationDelegate { - var window: NSWindow! + // MARK: - Properties + + private var window: NSWindow! + private var displayManager: VirtualDisplayManager! + private var mouseTracker: MouseTracker! + + // MARK: - Application Lifecycle func applicationDidFinishLaunching(_: Notification) { - let viewController = ScreenViewController() + // Create managers + displayManager = VirtualDisplayManager() + mouseTracker = MouseTracker() + + // Create view controller + let viewController = DeskPadViewController( + displayManager: displayManager, + mouseTracker: mouseTracker + ) + + // Create and configure window window = NSWindow(contentViewController: viewController) + configureWindow() + + // Set window delegate window.delegate = viewController - window.title = "DeskPad" + + // Show window window.makeKeyAndOrderFront(nil) + + // Setup application menu + setupMenu() + } + + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + true + } + + // MARK: - Window Configuration + + private func configureWindow() { + window.title = "DeskPad" window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = true window.titleVisibility = .hidden @@ -23,24 +51,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate { window.contentMaxSize = CGSize(width: 3840, height: 2160) window.styleMask.insert(.resizable) window.collectionBehavior.insert(.fullScreenNone) + } + + // MARK: - Menu Setup + private func setupMenu() { let mainMenu = NSMenu() - let mainMenuItem = NSMenuItem() - let subMenu = NSMenu(title: "MainMenu") - let quitMenuItem = NSMenuItem( - title: "Quit", + + // Application menu + let appMenuItem = NSMenuItem() + let appMenu = NSMenu(title: "DeskPad") + + let quitItem = NSMenuItem( + title: "Quit DeskPad", action: #selector(NSApp.terminate), keyEquivalent: "q" ) - subMenu.addItem(quitMenuItem) - mainMenuItem.submenu = subMenu - mainMenu.items = [mainMenuItem] - NSApplication.shared.mainMenu = mainMenu + appMenu.addItem(quitItem) - store.dispatch(AppDelegateAction.didFinishLaunching) - } + appMenuItem.submenu = appMenu + mainMenu.addItem(appMenuItem) - func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { - return true + NSApplication.shared.mainMenu = mainMenu } } diff --git a/DeskPad/Core/DeskPadViewController.swift b/DeskPad/Core/DeskPadViewController.swift new file mode 100644 index 0000000..f84c9a3 --- /dev/null +++ b/DeskPad/Core/DeskPadViewController.swift @@ -0,0 +1,155 @@ +import Cocoa +import Combine + +/// Main view controller for the DeskPad display window +final class DeskPadViewController: NSViewController, NSWindowDelegate { + // MARK: - Dependencies + + private let displayManager: VirtualDisplayManager + private let mouseTracker: MouseTracker + + // MARK: - Views + + private var renderer: DisplayStreamRenderer! + + // MARK: - State + + private var cancellables = Set() + private var isWindowHighlighted = false + + // MARK: - Constants + + private enum Constants { + static let windowSnappingThreshold: CGFloat = 30 + } + + // MARK: - Initialization + + init(displayManager: VirtualDisplayManager, mouseTracker: MouseTracker) { + self.displayManager = displayManager + self.mouseTracker = mouseTracker + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func loadView() { + renderer = DisplayStreamRenderer(frame: .zero) + view = renderer + + // Add click gesture + let clickGesture = NSClickGestureRecognizer(target: self, action: #selector(handleClick(_:))) + view.addGestureRecognizer(clickGesture) + } + + override func viewDidLoad() { + super.viewDidLoad() + setupBindings() + } + + override func viewWillAppear() { + super.viewWillAppear() + + // Create virtual display when view appears + displayManager.create() + } + + override func viewDidDisappear() { + super.viewDidDisappear() + + // Clean up when view disappears + renderer.stopStream() + mouseTracker.stopTracking() + } + + // MARK: - Bindings + + private func setupBindings() { + // When display becomes ready, configure the renderer and start tracking + displayManager.$isReady + .combineLatest(displayManager.$displayID, displayManager.$resolution, displayManager.$scaleFactor) + .receive(on: DispatchQueue.main) + .sink { [weak self] isReady, displayID, resolution, scaleFactor in + guard isReady, let displayID = displayID, resolution != .zero else { return } + self?.configureDisplay(displayID: displayID, resolution: resolution, scaleFactor: scaleFactor) + } + .store(in: &cancellables) + + // Update window highlight based on mouse position + mouseTracker.$isWithinVirtualDisplay + .receive(on: DispatchQueue.main) + .sink { [weak self] isWithin in + self?.updateWindowHighlight(isWithin) + } + .store(in: &cancellables) + } + + // MARK: - Display Configuration + + private func configureDisplay(displayID: CGDirectDisplayID, resolution: CGSize, scaleFactor: CGFloat) { + // Configure window + configureWindow(for: resolution) + + // Start streaming + renderer.configure(displayID: displayID, resolution: resolution, scaleFactor: scaleFactor) + + // Start mouse tracking + mouseTracker.startTracking(displayID: displayID) + } + + private func configureWindow(for resolution: CGSize) { + guard let window = view.window else { return } + + window.setContentSize(resolution) + window.contentAspectRatio = resolution + window.center() + } + + // MARK: - Window Highlight + + private func updateWindowHighlight(_ highlighted: Bool) { + guard highlighted != isWindowHighlighted else { return } + isWindowHighlighted = highlighted + + view.window?.backgroundColor = highlighted + ? NSColor(named: "TitleBarActive") ?? .windowBackgroundColor + : NSColor(named: "TitleBarInactive") ?? .white + + if highlighted { + view.window?.orderFrontRegardless() + } + } + + // MARK: - Input Handling + + @objc private func handleClick(_ gesture: NSClickGestureRecognizer) { + let viewPoint = gesture.location(in: view) + + guard let displayPoint = renderer.convertToDisplayCoordinates(viewPoint) else { + return + } + + mouseTracker.moveCursor(to: displayPoint) + } + + // MARK: - NSWindowDelegate + + func windowWillResize(_ window: NSWindow, to frameSize: NSSize) -> NSSize { + let contentSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: frameSize)).size + + // Snap to exact resolution if close + let resolution = displayManager.resolution + if resolution != .zero, + abs(contentSize.width - resolution.width) < Constants.windowSnappingThreshold + { + return window.frameRect(forContentRect: NSRect(origin: .zero, size: resolution)).size + } + + return frameSize + } +} diff --git a/DeskPad/Core/DisplayStreamRenderer.swift b/DeskPad/Core/DisplayStreamRenderer.swift new file mode 100644 index 0000000..949806a --- /dev/null +++ b/DeskPad/Core/DisplayStreamRenderer.swift @@ -0,0 +1,149 @@ +import Cocoa +import Combine +import Metal +import MetalKit + +/// Metal-based renderer for the display stream +final class DisplayStreamRenderer: NSView { + // MARK: - Properties + + private var displayStream: CGDisplayStream? + private var metalLayer: CAMetalLayer! + private var device: MTLDevice! + private var commandQueue: MTLCommandQueue! + private var pipelineState: MTLRenderPipelineState! + + private var currentDisplayID: CGDirectDisplayID? + private var currentResolution: CGSize = .zero + private var currentScaleFactor: CGFloat = 1.0 + + /// BGRA pixel format as 32-bit integer + private let pixelFormat: Int32 = 1_111_970_369 + + // MARK: - Initialization + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupMetal() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupMetal() + } + + deinit { + stopStream() + } + + // MARK: - Setup + + private func setupMetal() { + wantsLayer = true + + guard let device = MTLCreateSystemDefaultDevice() else { + print("Metal is not supported on this device") + return + } + + self.device = device + self.commandQueue = device.makeCommandQueue() + + // Create Metal layer + metalLayer = CAMetalLayer() + metalLayer.device = device + metalLayer.pixelFormat = .bgra8Unorm + metalLayer.framebufferOnly = false + metalLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 + + layer = metalLayer + } + + override func layout() { + super.layout() + metalLayer?.frame = bounds + metalLayer?.drawableSize = CGSize( + width: bounds.width * (window?.backingScaleFactor ?? 2.0), + height: bounds.height * (window?.backingScaleFactor ?? 2.0) + ) + } + + // MARK: - Public Methods + + /// Configures and starts the display stream + func configure(displayID: CGDirectDisplayID, resolution: CGSize, scaleFactor: CGFloat) { + // Skip if already configured with same parameters + if displayID == currentDisplayID, + resolution == currentResolution, + scaleFactor == currentScaleFactor, + displayStream != nil + { + return + } + + // Stop existing stream + stopStream() + + // Store current configuration + currentDisplayID = displayID + currentResolution = resolution + currentScaleFactor = scaleFactor + + // Calculate output dimensions + let outputWidth = Int(resolution.width * scaleFactor) + let outputHeight = Int(resolution.height * scaleFactor) + + // Create new display stream + let stream = CGDisplayStream( + dispatchQueueDisplay: displayID, + outputWidth: outputWidth, + outputHeight: outputHeight, + pixelFormat: pixelFormat, + properties: [ + CGDisplayStream.showCursor: true, + ] as CFDictionary, + queue: .main, + handler: { [weak self] _, _, frameSurface, _ in + guard let surface = frameSurface else { return } + self?.renderFrame(surface) + } + ) + + self.displayStream = stream + stream?.start() + } + + /// Stops the display stream + func stopStream() { + displayStream?.stop() + displayStream = nil + } + + // MARK: - Rendering + + private func renderFrame(_ surface: IOSurfaceRef) { + // For simplicity and efficiency, we'll use the CALayer contents approach + // which is already GPU-optimized via IOSurface + // Metal rendering would be used for additional post-processing + CATransaction.begin() + CATransaction.setDisableActions(true) + metalLayer.contents = surface + CATransaction.commit() + } + + // MARK: - Coordinate Conversion + + /// Converts a point in view coordinates to display coordinates + func convertToDisplayCoordinates(_ viewPoint: NSPoint) -> NSPoint? { + guard currentResolution != .zero else { return nil } + + let normalizedX = viewPoint.x / bounds.width + // Flip Y coordinate (view origin is bottom-left, but we want top-left for display) + let normalizedY = (bounds.height - viewPoint.y) / bounds.height + + return NSPoint( + x: normalizedX * currentResolution.width, + y: normalizedY * currentResolution.height + ) + } +} diff --git a/DeskPad/Core/MouseTracker.swift b/DeskPad/Core/MouseTracker.swift new file mode 100644 index 0000000..f807706 --- /dev/null +++ b/DeskPad/Core/MouseTracker.swift @@ -0,0 +1,70 @@ +import Combine +import Foundation + +/// Tracks mouse position and handles cursor movement +final class MouseTracker: ObservableObject { + // MARK: - Published State + + @Published private(set) var isWithinVirtualDisplay = false + + // MARK: - Private Properties + + private var timer: Timer? + private var displayID: CGDirectDisplayID? + private let trackingInterval: TimeInterval = 0.25 + + // MARK: - Initialization + + init() {} + + deinit { + stopTracking() + } + + // MARK: - Public Methods + + /// Starts tracking mouse position relative to the virtual display + func startTracking(displayID: CGDirectDisplayID) { + self.displayID = displayID + + // Stop any existing timer + stopTracking() + + // Start polling timer + timer = Timer.scheduledTimer(withTimeInterval: trackingInterval, repeats: true) { [weak self] _ in + self?.updateMouseLocation() + } + } + + /// Stops tracking mouse position + func stopTracking() { + timer?.invalidate() + timer = nil + } + + /// Moves the cursor to the specified point on the virtual display + func moveCursor(to point: NSPoint) { + guard let displayID = displayID else { return } + CGDisplayMoveCursorToPoint(displayID, point) + } + + // MARK: - Private Methods + + private func updateMouseLocation() { + guard let displayID = displayID else { + isWithinVirtualDisplay = false + return + } + + let mouseLocation = NSEvent.mouseLocation + let screens = NSScreen.screens + + // Find which screen contains the mouse + let screenContainingMouse = screens.first { screen in + NSMouseInRect(mouseLocation, screen.frame, false) + } + + // Check if it's the virtual display + isWithinVirtualDisplay = screenContainingMouse?.displayID == displayID + } +} diff --git a/DeskPad/Core/VirtualDisplayManager.swift b/DeskPad/Core/VirtualDisplayManager.swift new file mode 100644 index 0000000..081e1ad --- /dev/null +++ b/DeskPad/Core/VirtualDisplayManager.swift @@ -0,0 +1,135 @@ +import Combine +import Foundation + +/// Manages the lifecycle of the virtual display +final class VirtualDisplayManager: ObservableObject { + // MARK: - Published State + + @Published private(set) var displayID: CGDirectDisplayID? + @Published private(set) var resolution: CGSize = .zero + @Published private(set) var scaleFactor: CGFloat = 1.0 + @Published private(set) var isReady = false + + // MARK: - Private Properties + + private var virtualDisplay: CGVirtualDisplay? + private var notificationObserver: NSObjectProtocol? + + // MARK: - Constants + + private enum Constants { + static let maxWidth: UInt32 = 3840 + static let maxHeight: UInt32 = 2160 + static let physicalSize = CGSize(width: 1600, height: 1000) // mm + static let vendorID: UInt32 = 0x3456 + static let productID: UInt32 = 0x1234 + static let serialNum: UInt32 = 0x0001 + static let refreshRate: CGFloat = 60 + + static let displayModes: [CGVirtualDisplayMode] = [ + // 16:9 aspect ratio + CGVirtualDisplayMode(width: 3840, height: 2160, refreshRate: refreshRate), + CGVirtualDisplayMode(width: 2560, height: 1440, refreshRate: refreshRate), + CGVirtualDisplayMode(width: 1920, height: 1080, refreshRate: refreshRate), + CGVirtualDisplayMode(width: 1600, height: 900, refreshRate: refreshRate), + CGVirtualDisplayMode(width: 1366, height: 768, refreshRate: refreshRate), + CGVirtualDisplayMode(width: 1280, height: 720, refreshRate: refreshRate), + // 16:10 aspect ratio + CGVirtualDisplayMode(width: 2560, height: 1600, refreshRate: refreshRate), + CGVirtualDisplayMode(width: 1920, height: 1200, refreshRate: refreshRate), + CGVirtualDisplayMode(width: 1680, height: 1050, refreshRate: refreshRate), + CGVirtualDisplayMode(width: 1440, height: 900, refreshRate: refreshRate), + CGVirtualDisplayMode(width: 1280, height: 800, refreshRate: refreshRate), + ] + } + + // MARK: - Initialization + + init() {} + + deinit { + destroy() + } + + // MARK: - Public Methods + + /// Creates and configures the virtual display + func create() { + guard virtualDisplay == nil else { return } + + // Create descriptor + let descriptor = CGVirtualDisplayDescriptor() + descriptor.setDispatchQueue(DispatchQueue.main) + descriptor.name = "DeskPad Display" + descriptor.maxPixelsWide = Constants.maxWidth + descriptor.maxPixelsHigh = Constants.maxHeight + descriptor.sizeInMillimeters = Constants.physicalSize + descriptor.vendorID = Constants.vendorID + descriptor.productID = Constants.productID + descriptor.serialNum = Constants.serialNum + + // Create the virtual display + let display = CGVirtualDisplay(descriptor: descriptor) + self.virtualDisplay = display + self.displayID = display.displayID + + // Configure settings + let settings = CGVirtualDisplaySettings() + settings.hiDPI = 1 + settings.modes = Constants.displayModes + display.apply(settings) + + // Start observing screen parameter changes + startObservingScreenChanges() + + // Query initial configuration after a brief delay to allow system to recognize display + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.updateScreenConfiguration() + } + } + + /// Destroys the virtual display + func destroy() { + stopObservingScreenChanges() + virtualDisplay = nil + displayID = nil + resolution = .zero + scaleFactor = 1.0 + isReady = false + } + + // MARK: - Private Methods + + private func startObservingScreenChanges() { + notificationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: NSApplication.shared, + queue: .main + ) { [weak self] _ in + self?.updateScreenConfiguration() + } + } + + private func stopObservingScreenChanges() { + if let observer = notificationObserver { + NotificationCenter.default.removeObserver(observer) + notificationObserver = nil + } + } + + private func updateScreenConfiguration() { + guard let displayID = displayID else { return } + + guard let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) else { + // Display not yet recognized by system, retry + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.updateScreenConfiguration() + } + return + } + + resolution = screen.frame.size + scaleFactor = screen.backingScaleFactor + isReady = true + } +} From 557944e4258488a49d88f076d6393777e5991c95 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 23:06:05 +0000 Subject: [PATCH 09/13] Add comprehensive debug logging to diagnose display initialization Added detailed print statements throughout the initialization flow: - VirtualDisplayManager: display creation, settings, screen detection retry - DisplayStreamRenderer: Metal setup, stream creation, frame rendering - DeskPadViewController: ready state handling, resolution changes - AppDelegate: applicationSupportsSecureRestorableState to fix warning This logging will help identify where the display stream initialization is failing. https://claude.ai/code/session_01LsaxVzrkjDJMK8PYpthKvX --- DeskPad/AppDelegate.swift | 18 ++-- DeskPad/Core/DeskPadViewController.swift | 104 +++++++++++++++-------- DeskPad/Core/DisplayStreamRenderer.swift | 34 ++++++-- DeskPad/Core/VirtualDisplayManager.swift | 46 ++++++++-- 4 files changed, 143 insertions(+), 59 deletions(-) diff --git a/DeskPad/AppDelegate.swift b/DeskPad/AppDelegate.swift index 6fb4c59..7a76fd4 100644 --- a/DeskPad/AppDelegate.swift +++ b/DeskPad/AppDelegate.swift @@ -5,21 +5,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Properties private var window: NSWindow! - private var displayManager: VirtualDisplayManager! - private var mouseTracker: MouseTracker! + private var viewController: DeskPadViewController! // MARK: - Application Lifecycle func applicationDidFinishLaunching(_: Notification) { - // Create managers - displayManager = VirtualDisplayManager() - mouseTracker = MouseTracker() - - // Create view controller - let viewController = DeskPadViewController( - displayManager: displayManager, - mouseTracker: mouseTracker - ) + // Create view controller (which creates display in viewDidLoad) + viewController = DeskPadViewController() // Create and configure window window = NSWindow(contentViewController: viewController) @@ -39,6 +31,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { true } + func applicationSupportsSecureRestorableState(_: NSApplication) -> Bool { + true + } + // MARK: - Window Configuration private func configureWindow() { diff --git a/DeskPad/Core/DeskPadViewController.swift b/DeskPad/Core/DeskPadViewController.swift index f84c9a3..c96d7ef 100644 --- a/DeskPad/Core/DeskPadViewController.swift +++ b/DeskPad/Core/DeskPadViewController.swift @@ -3,17 +3,11 @@ import Combine /// Main view controller for the DeskPad display window final class DeskPadViewController: NSViewController, NSWindowDelegate { - // MARK: - Dependencies - - private let displayManager: VirtualDisplayManager - private let mouseTracker: MouseTracker - - // MARK: - Views + // MARK: - Properties + private var displayManager: VirtualDisplayManager! + private var mouseTracker: MouseTracker! private var renderer: DisplayStreamRenderer! - - // MARK: - State - private var cancellables = Set() private var isWindowHighlighted = false @@ -25,10 +19,8 @@ final class DeskPadViewController: NSViewController, NSWindowDelegate { // MARK: - Initialization - init(displayManager: VirtualDisplayManager, mouseTracker: MouseTracker) { - self.displayManager = displayManager - self.mouseTracker = mouseTracker - super.init(nibName: nil, bundle: nil) + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) } @available(*, unavailable) @@ -39,7 +31,7 @@ final class DeskPadViewController: NSViewController, NSWindowDelegate { // MARK: - View Lifecycle override func loadView() { - renderer = DisplayStreamRenderer(frame: .zero) + renderer = DisplayStreamRenderer(frame: NSRect(x: 0, y: 0, width: 1280, height: 720)) view = renderer // Add click gesture @@ -49,34 +41,34 @@ final class DeskPadViewController: NSViewController, NSWindowDelegate { override func viewDidLoad() { super.viewDidLoad() - setupBindings() - } - override func viewWillAppear() { - super.viewWillAppear() + // Create managers + displayManager = VirtualDisplayManager() + mouseTracker = MouseTracker() - // Create virtual display when view appears + // Set up bindings for state changes + setupBindings() + + // Create virtual display immediately displayManager.create() } override func viewDidDisappear() { super.viewDidDisappear() - - // Clean up when view disappears renderer.stopStream() mouseTracker.stopTracking() + displayManager.destroy() } // MARK: - Bindings private func setupBindings() { - // When display becomes ready, configure the renderer and start tracking + // When display becomes ready, configure everything displayManager.$isReady - .combineLatest(displayManager.$displayID, displayManager.$resolution, displayManager.$scaleFactor) .receive(on: DispatchQueue.main) - .sink { [weak self] isReady, displayID, resolution, scaleFactor in - guard isReady, let displayID = displayID, resolution != .zero else { return } - self?.configureDisplay(displayID: displayID, resolution: resolution, scaleFactor: scaleFactor) + .sink { [weak self] isReady in + guard isReady else { return } + self?.onDisplayReady() } .store(in: &cancellables) @@ -87,27 +79,67 @@ final class DeskPadViewController: NSViewController, NSWindowDelegate { self?.updateWindowHighlight(isWithin) } .store(in: &cancellables) + + // React to resolution changes + displayManager.$resolution + .dropFirst() // Skip initial value + .receive(on: DispatchQueue.main) + .sink { [weak self] resolution in + guard let self, resolution != .zero else { return } + self.onResolutionChanged(resolution) + } + .store(in: &cancellables) } // MARK: - Display Configuration - private func configureDisplay(displayID: CGDirectDisplayID, resolution: CGSize, scaleFactor: CGFloat) { - // Configure window - configureWindow(for: resolution) + private func onDisplayReady() { + print("[DeskPadViewController] onDisplayReady called") + + guard let displayID = displayManager.displayID else { + print("[DeskPadViewController] No displayID") + return + } + + let resolution = displayManager.resolution + let scaleFactor = displayManager.scaleFactor + + print("[DeskPadViewController] Resolution: \(resolution), Scale: \(scaleFactor)") + + // Configure window size + if let window = view.window, resolution != .zero { + print("[DeskPadViewController] Setting window size to: \(resolution)") + window.setContentSize(resolution) + window.contentAspectRatio = resolution + window.center() + } // Start streaming - renderer.configure(displayID: displayID, resolution: resolution, scaleFactor: scaleFactor) + if resolution != .zero { + print("[DeskPadViewController] Starting stream for displayID: \(displayID)") + renderer.configure(displayID: displayID, resolution: resolution, scaleFactor: scaleFactor) + } // Start mouse tracking mouseTracker.startTracking(displayID: displayID) + print("[DeskPadViewController] Mouse tracking started") } - private func configureWindow(for resolution: CGSize) { - guard let window = view.window else { return } + private func onResolutionChanged(_ resolution: CGSize) { + guard let displayID = displayManager.displayID else { return } + + // Update window + if let window = view.window { + window.setContentSize(resolution) + window.contentAspectRatio = resolution + } - window.setContentSize(resolution) - window.contentAspectRatio = resolution - window.center() + // Reconfigure stream + renderer.configure( + displayID: displayID, + resolution: resolution, + scaleFactor: displayManager.scaleFactor + ) } // MARK: - Window Highlight @@ -141,9 +173,9 @@ final class DeskPadViewController: NSViewController, NSWindowDelegate { func windowWillResize(_ window: NSWindow, to frameSize: NSSize) -> NSSize { let contentSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: frameSize)).size + let resolution = displayManager.resolution // Snap to exact resolution if close - let resolution = displayManager.resolution if resolution != .zero, abs(contentSize.width - resolution.width) < Constants.windowSnappingThreshold { diff --git a/DeskPad/Core/DisplayStreamRenderer.swift b/DeskPad/Core/DisplayStreamRenderer.swift index 949806a..5738d0f 100644 --- a/DeskPad/Core/DisplayStreamRenderer.swift +++ b/DeskPad/Core/DisplayStreamRenderer.swift @@ -39,10 +39,11 @@ final class DisplayStreamRenderer: NSView { // MARK: - Setup private func setupMetal() { + print("[DisplayStreamRenderer] setupMetal called") wantsLayer = true guard let device = MTLCreateSystemDefaultDevice() else { - print("Metal is not supported on this device") + print("[DisplayStreamRenderer] Metal is not supported on this device") return } @@ -57,6 +58,7 @@ final class DisplayStreamRenderer: NSView { metalLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 layer = metalLayer + print("[DisplayStreamRenderer] Metal setup complete") } override func layout() { @@ -72,12 +74,15 @@ final class DisplayStreamRenderer: NSView { /// Configures and starts the display stream func configure(displayID: CGDirectDisplayID, resolution: CGSize, scaleFactor: CGFloat) { + print("[DisplayStreamRenderer] configure called - displayID: \(displayID), resolution: \(resolution), scaleFactor: \(scaleFactor)") + // Skip if already configured with same parameters if displayID == currentDisplayID, resolution == currentResolution, scaleFactor == currentScaleFactor, displayStream != nil { + print("[DisplayStreamRenderer] Already configured with same parameters, skipping") return } @@ -93,6 +98,8 @@ final class DisplayStreamRenderer: NSView { let outputWidth = Int(resolution.width * scaleFactor) let outputHeight = Int(resolution.height * scaleFactor) + print("[DisplayStreamRenderer] Creating stream with outputWidth: \(outputWidth), outputHeight: \(outputHeight)") + // Create new display stream let stream = CGDisplayStream( dispatchQueueDisplay: displayID, @@ -103,14 +110,24 @@ final class DisplayStreamRenderer: NSView { CGDisplayStream.showCursor: true, ] as CFDictionary, queue: .main, - handler: { [weak self] _, _, frameSurface, _ in - guard let surface = frameSurface else { return } + handler: { [weak self] status, _, frameSurface, _ in + if status != .frameComplete { + print("[DisplayStreamRenderer] Stream status: \(status.rawValue)") + } + guard let surface = frameSurface else { + return + } self?.renderFrame(surface) } ) - self.displayStream = stream - stream?.start() + if let stream = stream { + self.displayStream = stream + let startResult = stream.start() + print("[DisplayStreamRenderer] Stream created and started with result: \(startResult.rawValue)") + } else { + print("[DisplayStreamRenderer] ERROR: Failed to create CGDisplayStream!") + } } /// Stops the display stream @@ -121,7 +138,14 @@ final class DisplayStreamRenderer: NSView { // MARK: - Rendering + private var frameCount = 0 + private func renderFrame(_ surface: IOSurfaceRef) { + frameCount += 1 + if frameCount == 1 || frameCount % 60 == 0 { + print("[DisplayStreamRenderer] Rendering frame #\(frameCount)") + } + // For simplicity and efficiency, we'll use the CALayer contents approach // which is already GPU-optimized via IOSurface // Metal rendering would be used for additional post-processing diff --git a/DeskPad/Core/VirtualDisplayManager.swift b/DeskPad/Core/VirtualDisplayManager.swift index 081e1ad..099fd95 100644 --- a/DeskPad/Core/VirtualDisplayManager.swift +++ b/DeskPad/Core/VirtualDisplayManager.swift @@ -14,6 +14,8 @@ final class VirtualDisplayManager: ObservableObject { private var virtualDisplay: CGVirtualDisplay? private var notificationObserver: NSObjectProtocol? + private var retryCount = 0 + private let maxRetries = 50 // 5 seconds max at 100ms intervals // MARK: - Constants @@ -45,7 +47,9 @@ final class VirtualDisplayManager: ObservableObject { // MARK: - Initialization - init() {} + init() { + print("[VirtualDisplayManager] Initialized") + } deinit { destroy() @@ -55,7 +59,12 @@ final class VirtualDisplayManager: ObservableObject { /// Creates and configures the virtual display func create() { - guard virtualDisplay == nil else { return } + guard virtualDisplay == nil else { + print("[VirtualDisplayManager] Already created") + return + } + + print("[VirtualDisplayManager] Creating virtual display...") // Create descriptor let descriptor = CGVirtualDisplayDescriptor() @@ -73,16 +82,21 @@ final class VirtualDisplayManager: ObservableObject { self.virtualDisplay = display self.displayID = display.displayID + print("[VirtualDisplayManager] Display created with ID: \(display.displayID)") + // Configure settings let settings = CGVirtualDisplaySettings() settings.hiDPI = 1 settings.modes = Constants.displayModes display.apply(settings) + print("[VirtualDisplayManager] Settings applied") + // Start observing screen parameter changes startObservingScreenChanges() - // Query initial configuration after a brief delay to allow system to recognize display + // Query initial configuration after a brief delay + retryCount = 0 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in self?.updateScreenConfiguration() } @@ -90,6 +104,7 @@ final class VirtualDisplayManager: ObservableObject { /// Destroys the virtual display func destroy() { + print("[VirtualDisplayManager] Destroying...") stopObservingScreenChanges() virtualDisplay = nil displayID = nil @@ -101,11 +116,13 @@ final class VirtualDisplayManager: ObservableObject { // MARK: - Private Methods private func startObservingScreenChanges() { + print("[VirtualDisplayManager] Starting notification observer") notificationObserver = NotificationCenter.default.addObserver( forName: NSApplication.didChangeScreenParametersNotification, object: NSApplication.shared, queue: .main ) { [weak self] _ in + print("[VirtualDisplayManager] Received screen parameters notification") self?.updateScreenConfiguration() } } @@ -118,18 +135,33 @@ final class VirtualDisplayManager: ObservableObject { } private func updateScreenConfiguration() { - guard let displayID = displayID else { return } + guard let displayID = displayID else { + print("[VirtualDisplayManager] No displayID") + return + } + + print("[VirtualDisplayManager] Looking for screen with displayID: \(displayID)") + print("[VirtualDisplayManager] Available screens: \(NSScreen.screens.map { "\($0.displayID)" }.joined(separator: ", "))") guard let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) else { - // Display not yet recognized by system, retry - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.updateScreenConfiguration() + retryCount += 1 + if retryCount < maxRetries { + print("[VirtualDisplayManager] Display not found, retry \(retryCount)/\(maxRetries)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.updateScreenConfiguration() + } + } else { + print("[VirtualDisplayManager] Max retries reached, display not found") } return } + print("[VirtualDisplayManager] Found screen: \(screen.frame.size), scale: \(screen.backingScaleFactor)") + resolution = screen.frame.size scaleFactor = screen.backingScaleFactor isReady = true + + print("[VirtualDisplayManager] isReady = true") } } From 89ba5aa6ebe7a490aaebe4cb63dd14dc03caabd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 23:10:55 +0000 Subject: [PATCH 10/13] Simplify DisplayStreamRenderer and add early logging - Removed Metal complexity from DisplayStreamRenderer - Use simple layer.contents approach like original code - Added logging to AppDelegate to trace initialization flow The simplified approach matches the working original implementation. https://claude.ai/code/session_01LsaxVzrkjDJMK8PYpthKvX --- DeskPad/AppDelegate.swift | 8 +++ DeskPad/Core/DisplayStreamRenderer.swift | 91 ++++-------------------- 2 files changed, 21 insertions(+), 78 deletions(-) diff --git a/DeskPad/AppDelegate.swift b/DeskPad/AppDelegate.swift index 7a76fd4..91d3bde 100644 --- a/DeskPad/AppDelegate.swift +++ b/DeskPad/AppDelegate.swift @@ -10,21 +10,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Application Lifecycle func applicationDidFinishLaunching(_: Notification) { + print("[AppDelegate] applicationDidFinishLaunching") + // Create view controller (which creates display in viewDidLoad) viewController = DeskPadViewController() + print("[AppDelegate] DeskPadViewController created") // Create and configure window window = NSWindow(contentViewController: viewController) + print("[AppDelegate] Window created") + configureWindow() + print("[AppDelegate] Window configured") // Set window delegate window.delegate = viewController // Show window window.makeKeyAndOrderFront(nil) + print("[AppDelegate] Window shown") // Setup application menu setupMenu() + print("[AppDelegate] Setup complete") } func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { diff --git a/DeskPad/Core/DisplayStreamRenderer.swift b/DeskPad/Core/DisplayStreamRenderer.swift index 5738d0f..7ac30e0 100644 --- a/DeskPad/Core/DisplayStreamRenderer.swift +++ b/DeskPad/Core/DisplayStreamRenderer.swift @@ -1,18 +1,10 @@ import Cocoa -import Combine -import Metal -import MetalKit -/// Metal-based renderer for the display stream +/// Simple layer-based renderer for the display stream final class DisplayStreamRenderer: NSView { // MARK: - Properties private var displayStream: CGDisplayStream? - private var metalLayer: CAMetalLayer! - private var device: MTLDevice! - private var commandQueue: MTLCommandQueue! - private var pipelineState: MTLRenderPipelineState! - private var currentDisplayID: CGDirectDisplayID? private var currentResolution: CGSize = .zero private var currentScaleFactor: CGFloat = 1.0 @@ -24,52 +16,19 @@ final class DisplayStreamRenderer: NSView { override init(frame frameRect: NSRect) { super.init(frame: frameRect) - setupMetal() + wantsLayer = true + print("[DisplayStreamRenderer] Initialized with frame: \(frameRect)") } - required init?(coder: NSCoder) { - super.init(coder: coder) - setupMetal() + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") } deinit { stopStream() } - // MARK: - Setup - - private func setupMetal() { - print("[DisplayStreamRenderer] setupMetal called") - wantsLayer = true - - guard let device = MTLCreateSystemDefaultDevice() else { - print("[DisplayStreamRenderer] Metal is not supported on this device") - return - } - - self.device = device - self.commandQueue = device.makeCommandQueue() - - // Create Metal layer - metalLayer = CAMetalLayer() - metalLayer.device = device - metalLayer.pixelFormat = .bgra8Unorm - metalLayer.framebufferOnly = false - metalLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 - - layer = metalLayer - print("[DisplayStreamRenderer] Metal setup complete") - } - - override func layout() { - super.layout() - metalLayer?.frame = bounds - metalLayer?.drawableSize = CGSize( - width: bounds.width * (window?.backingScaleFactor ?? 2.0), - height: bounds.height * (window?.backingScaleFactor ?? 2.0) - ) - } - // MARK: - Public Methods /// Configures and starts the display stream @@ -82,7 +41,7 @@ final class DisplayStreamRenderer: NSView { scaleFactor == currentScaleFactor, displayStream != nil { - print("[DisplayStreamRenderer] Already configured with same parameters, skipping") + print("[DisplayStreamRenderer] Already configured, skipping") return } @@ -98,7 +57,7 @@ final class DisplayStreamRenderer: NSView { let outputWidth = Int(resolution.width * scaleFactor) let outputHeight = Int(resolution.height * scaleFactor) - print("[DisplayStreamRenderer] Creating stream with outputWidth: \(outputWidth), outputHeight: \(outputHeight)") + print("[DisplayStreamRenderer] Creating stream - outputWidth: \(outputWidth), outputHeight: \(outputHeight)") // Create new display stream let stream = CGDisplayStream( @@ -110,21 +69,16 @@ final class DisplayStreamRenderer: NSView { CGDisplayStream.showCursor: true, ] as CFDictionary, queue: .main, - handler: { [weak self] status, _, frameSurface, _ in - if status != .frameComplete { - print("[DisplayStreamRenderer] Stream status: \(status.rawValue)") - } - guard let surface = frameSurface else { - return - } - self?.renderFrame(surface) + handler: { [weak self] _, _, frameSurface, _ in + guard let surface = frameSurface else { return } + self?.layer?.contents = surface } ) if let stream = stream { self.displayStream = stream - let startResult = stream.start() - print("[DisplayStreamRenderer] Stream created and started with result: \(startResult.rawValue)") + stream.start() + print("[DisplayStreamRenderer] Stream started successfully") } else { print("[DisplayStreamRenderer] ERROR: Failed to create CGDisplayStream!") } @@ -136,25 +90,6 @@ final class DisplayStreamRenderer: NSView { displayStream = nil } - // MARK: - Rendering - - private var frameCount = 0 - - private func renderFrame(_ surface: IOSurfaceRef) { - frameCount += 1 - if frameCount == 1 || frameCount % 60 == 0 { - print("[DisplayStreamRenderer] Rendering frame #\(frameCount)") - } - - // For simplicity and efficiency, we'll use the CALayer contents approach - // which is already GPU-optimized via IOSurface - // Metal rendering would be used for additional post-processing - CATransaction.begin() - CATransaction.setDisableActions(true) - metalLayer.contents = surface - CATransaction.commit() - } - // MARK: - Coordinate Conversion /// Converts a point in view coordinates to display coordinates From 7d0e031cc7bbf3ed558568aabf210ad8a2c063be Mon Sep 17 00:00:00 2001 From: xocialize Date: Mon, 26 Jan 2026 15:19:52 -0800 Subject: [PATCH 11/13] files --- .DS_Store | Bin 6148 -> 6148 bytes .../xcshareddata/swiftpm/Package.resolved | 25 +++++++++--------- DeskPad/Core/DisplayStreamRenderer.swift | 2 +- DeskPad/Core/VirtualDisplayManager.swift | 4 +-- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/.DS_Store b/.DS_Store index 70cd25d95f478b744bfc13a2677235b3055ba465..70eae280cf8836db073937be13cebf3a817ac219 100644 GIT binary patch delta 46 zcmZoMXffE}z{oU-cXAD*#^fwUF4pX%{9|I1D;Z5Wz^p099VhcJs%^f*s38UbS$7WV delta 46 zcmZoMXffE}z{u2>G`WURV{#TF7wapJ2Sq}YD;Z5W9+^B#6r6J0aWW61+U7fq8e#x= C?+_0F diff --git a/DeskPad.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DeskPad.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 60ef612..0049b53 100644 --- a/DeskPad.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DeskPad.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,16 +1,15 @@ { - "object": { - "pins": [ - { - "package": "ReSwift", - "repositoryURL": "https://github.com/ReSwift/ReSwift.git", - "state": { - "branch": null, - "revision": "96146a29f394ae4c79be025fcec194e5b0d9c3b6", - "version": "6.1.0" - } + "originHash" : "273817e893e86df1b0566b5a6ea32450961c1cbd811069d9a91a99a0d544740a", + "pins" : [ + { + "identity" : "reswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReSwift/ReSwift.git", + "state" : { + "revision" : "cb5c3c02f652420ef413dea41e13ac5a76b6c0fd", + "version" : "6.1.1" } - ] - }, - "version": 1 + } + ], + "version" : 3 } diff --git a/DeskPad/Core/DisplayStreamRenderer.swift b/DeskPad/Core/DisplayStreamRenderer.swift index 7ac30e0..6452c72 100644 --- a/DeskPad/Core/DisplayStreamRenderer.swift +++ b/DeskPad/Core/DisplayStreamRenderer.swift @@ -76,7 +76,7 @@ final class DisplayStreamRenderer: NSView { ) if let stream = stream { - self.displayStream = stream + displayStream = stream stream.start() print("[DisplayStreamRenderer] Stream started successfully") } else { diff --git a/DeskPad/Core/VirtualDisplayManager.swift b/DeskPad/Core/VirtualDisplayManager.swift index 099fd95..c9f81b2 100644 --- a/DeskPad/Core/VirtualDisplayManager.swift +++ b/DeskPad/Core/VirtualDisplayManager.swift @@ -79,8 +79,8 @@ final class VirtualDisplayManager: ObservableObject { // Create the virtual display let display = CGVirtualDisplay(descriptor: descriptor) - self.virtualDisplay = display - self.displayID = display.displayID + virtualDisplay = display + displayID = display.displayID print("[VirtualDisplayManager] Display created with ID: \(display.displayID)") From e9071608a90b5f0b9a179380ebdc0212e87568e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 23:25:42 +0000 Subject: [PATCH 12/13] Restore main.swift entry point to fix app initialization The original app used main.swift to manually set up NSApplication and its delegate. The @main attribute approach wasn't working correctly. This restores the original initialization pattern. https://claude.ai/code/session_01LsaxVzrkjDJMK8PYpthKvX --- DeskPad.xcodeproj/project.pbxproj | 4 ++++ DeskPad/AppDelegate.swift | 1 - DeskPad/main.swift | 7 +++++++ 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 DeskPad/main.swift diff --git a/DeskPad.xcodeproj/project.pbxproj b/DeskPad.xcodeproj/project.pbxproj index 53b6982..524aac1 100644 --- a/DeskPad.xcodeproj/project.pbxproj +++ b/DeskPad.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 6D68E1B2287ABDB900CD574A /* NSScreen+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D68E1B1287ABDB900CD574A /* NSScreen+Extensions.swift */; }; 6D68E1B4287ABFC800CD574A /* ScreenConfigurationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D68E1B3287ABFC800CD574A /* ScreenConfigurationState.swift */; }; 6DC044522801877F00281728 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC044512801877F00281728 /* AppDelegate.swift */; }; + MAIN001280C211E00A3A2E5 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = MAIN002280C211E00A3A2E5 /* main.swift */; }; 6DC044562801878100281728 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6DC044552801878100281728 /* Assets.xcassets */; }; /* End PBXBuildFile section */ @@ -47,6 +48,7 @@ 6D68E1B3287ABFC800CD574A /* ScreenConfigurationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenConfigurationState.swift; sourceTree = ""; }; 6DC0444E2801877F00281728 /* DeskPad.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DeskPad.app; sourceTree = BUILT_PRODUCTS_DIR; }; 6DC044512801877F00281728 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + MAIN002280C211E00A3A2E5 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 6DC044552801878100281728 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 6DC0445A2801878100281728 /* DeskPad.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DeskPad.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ @@ -151,6 +153,7 @@ COREGRP280C211E00A3A2E5 /* Core */, 6D2F1485280C20C800A3A2E5 /* SubscriberViewController.swift */, 6DC044512801877F00281728 /* AppDelegate.swift */, + MAIN002280C211E00A3A2E5 /* main.swift */, 6D2F1483280C201B00A3A2E5 /* Backend */, 6D2F1484280C202700A3A2E5 /* Frontend */, 6D68E1B0287ABDAB00CD574A /* Helpers */, @@ -276,6 +279,7 @@ 6D41B0A42879FBA8007CEB2F /* ScreenViewData.swift in Sources */, 6D2F148E280C211E00A3A2E5 /* ScreenViewController.swift in Sources */, 6DC044522801877F00281728 /* AppDelegate.swift in Sources */, + MAIN001280C211E00A3A2E5 /* main.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DeskPad/AppDelegate.swift b/DeskPad/AppDelegate.swift index 91d3bde..c8ed0e1 100644 --- a/DeskPad/AppDelegate.swift +++ b/DeskPad/AppDelegate.swift @@ -1,6 +1,5 @@ import Cocoa -@main final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Properties diff --git a/DeskPad/main.swift b/DeskPad/main.swift new file mode 100644 index 0000000..5d2c0ec --- /dev/null +++ b/DeskPad/main.swift @@ -0,0 +1,7 @@ +import AppKit + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate + +_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) From 4d7502c726aec19be88617f8c8a9a90c63dbabe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 23:36:19 +0000 Subject: [PATCH 13/13] Modernize codebase with Swift concurrency and Combine Changes: - Add @MainActor to VirtualDisplayManager, MouseTracker, DeskPadViewController - Replace Timer.scheduledTimer with Combine Timer.publish - Replace NotificationCenter.addObserver with Combine publisher - Remove all debug logging - Extract magic numbers to named Constants enums - Add applicationSupportsSecureRestorableState to fix warning - Clean up code structure with proper MARK sections The app now uses modern Swift patterns while maintaining the working main.swift entry point approach. https://claude.ai/code/session_01LsaxVzrkjDJMK8PYpthKvX --- DeskPad/AppDelegate.swift | 26 +++---- DeskPad/Core/DeskPadViewController.swift | 38 ++++------ DeskPad/Core/DisplayStreamRenderer.swift | 19 ++--- DeskPad/Core/MouseTracker.swift | 34 +++++---- DeskPad/Core/VirtualDisplayManager.swift | 94 +++++++++--------------- 5 files changed, 91 insertions(+), 120 deletions(-) diff --git a/DeskPad/AppDelegate.swift b/DeskPad/AppDelegate.swift index c8ed0e1..21f7b14 100644 --- a/DeskPad/AppDelegate.swift +++ b/DeskPad/AppDelegate.swift @@ -6,32 +6,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var window: NSWindow! private var viewController: DeskPadViewController! + // MARK: - Constants + + private enum Constants { + static let windowTitle = "DeskPad" + static let minContentSize = CGSize(width: 400, height: 300) + static let maxContentSize = CGSize(width: 3840, height: 2160) + } + // MARK: - Application Lifecycle func applicationDidFinishLaunching(_: Notification) { - print("[AppDelegate] applicationDidFinishLaunching") - // Create view controller (which creates display in viewDidLoad) viewController = DeskPadViewController() - print("[AppDelegate] DeskPadViewController created") // Create and configure window window = NSWindow(contentViewController: viewController) - print("[AppDelegate] Window created") - configureWindow() - print("[AppDelegate] Window configured") // Set window delegate window.delegate = viewController // Show window window.makeKeyAndOrderFront(nil) - print("[AppDelegate] Window shown") // Setup application menu setupMenu() - print("[AppDelegate] Setup complete") } func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { @@ -45,13 +45,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Window Configuration private func configureWindow() { - window.title = "DeskPad" + window.title = Constants.windowTitle window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = true window.titleVisibility = .hidden window.backgroundColor = .white - window.contentMinSize = CGSize(width: 400, height: 300) - window.contentMaxSize = CGSize(width: 3840, height: 2160) + window.contentMinSize = Constants.minContentSize + window.contentMaxSize = Constants.maxContentSize window.styleMask.insert(.resizable) window.collectionBehavior.insert(.fullScreenNone) } @@ -63,10 +63,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Application menu let appMenuItem = NSMenuItem() - let appMenu = NSMenu(title: "DeskPad") + let appMenu = NSMenu(title: Constants.windowTitle) let quitItem = NSMenuItem( - title: "Quit DeskPad", + title: "Quit \(Constants.windowTitle)", action: #selector(NSApp.terminate), keyEquivalent: "q" ) diff --git a/DeskPad/Core/DeskPadViewController.swift b/DeskPad/Core/DeskPadViewController.swift index c96d7ef..e87a677 100644 --- a/DeskPad/Core/DeskPadViewController.swift +++ b/DeskPad/Core/DeskPadViewController.swift @@ -2,6 +2,7 @@ import Cocoa import Combine /// Main view controller for the DeskPad display window +@MainActor final class DeskPadViewController: NSViewController, NSWindowDelegate { // MARK: - Properties @@ -15,6 +16,7 @@ final class DeskPadViewController: NSViewController, NSWindowDelegate { private enum Constants { static let windowSnappingThreshold: CGFloat = 30 + static let initialFrameSize = NSRect(x: 0, y: 0, width: 1280, height: 720) } // MARK: - Initialization @@ -31,7 +33,7 @@ final class DeskPadViewController: NSViewController, NSWindowDelegate { // MARK: - View Lifecycle override func loadView() { - renderer = DisplayStreamRenderer(frame: NSRect(x: 0, y: 0, width: 1280, height: 720)) + renderer = DisplayStreamRenderer(frame: Constants.initialFrameSize) view = renderer // Add click gesture @@ -94,21 +96,13 @@ final class DeskPadViewController: NSViewController, NSWindowDelegate { // MARK: - Display Configuration private func onDisplayReady() { - print("[DeskPadViewController] onDisplayReady called") - - guard let displayID = displayManager.displayID else { - print("[DeskPadViewController] No displayID") - return - } + guard let displayID = displayManager.displayID else { return } let resolution = displayManager.resolution let scaleFactor = displayManager.scaleFactor - print("[DeskPadViewController] Resolution: \(resolution), Scale: \(scaleFactor)") - // Configure window size if let window = view.window, resolution != .zero { - print("[DeskPadViewController] Setting window size to: \(resolution)") window.setContentSize(resolution) window.contentAspectRatio = resolution window.center() @@ -116,13 +110,11 @@ final class DeskPadViewController: NSViewController, NSWindowDelegate { // Start streaming if resolution != .zero { - print("[DeskPadViewController] Starting stream for displayID: \(displayID)") renderer.configure(displayID: displayID, resolution: resolution, scaleFactor: scaleFactor) } // Start mouse tracking mouseTracker.startTracking(displayID: displayID) - print("[DeskPadViewController] Mouse tracking started") } private func onResolutionChanged(_ resolution: CGSize) { @@ -171,17 +163,19 @@ final class DeskPadViewController: NSViewController, NSWindowDelegate { // MARK: - NSWindowDelegate - func windowWillResize(_ window: NSWindow, to frameSize: NSSize) -> NSSize { - let contentSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: frameSize)).size - let resolution = displayManager.resolution + nonisolated func windowWillResize(_ window: NSWindow, to frameSize: NSSize) -> NSSize { + MainActor.assumeIsolated { + let contentSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: frameSize)).size + let resolution = displayManager.resolution - // Snap to exact resolution if close - if resolution != .zero, - abs(contentSize.width - resolution.width) < Constants.windowSnappingThreshold - { - return window.frameRect(forContentRect: NSRect(origin: .zero, size: resolution)).size - } + // Snap to exact resolution if close + if resolution != .zero, + abs(contentSize.width - resolution.width) < Constants.windowSnappingThreshold + { + return window.frameRect(forContentRect: NSRect(origin: .zero, size: resolution)).size + } - return frameSize + return frameSize + } } } diff --git a/DeskPad/Core/DisplayStreamRenderer.swift b/DeskPad/Core/DisplayStreamRenderer.swift index 6452c72..7173faf 100644 --- a/DeskPad/Core/DisplayStreamRenderer.swift +++ b/DeskPad/Core/DisplayStreamRenderer.swift @@ -9,15 +9,18 @@ final class DisplayStreamRenderer: NSView { private var currentResolution: CGSize = .zero private var currentScaleFactor: CGFloat = 1.0 - /// BGRA pixel format as 32-bit integer - private let pixelFormat: Int32 = 1_111_970_369 + // MARK: - Constants + + private enum Constants { + /// BGRA pixel format as 32-bit integer ('BGRA') + static let bgraPixelFormat: Int32 = 1_111_970_369 + } // MARK: - Initialization override init(frame frameRect: NSRect) { super.init(frame: frameRect) wantsLayer = true - print("[DisplayStreamRenderer] Initialized with frame: \(frameRect)") } @available(*, unavailable) @@ -33,15 +36,12 @@ final class DisplayStreamRenderer: NSView { /// Configures and starts the display stream func configure(displayID: CGDirectDisplayID, resolution: CGSize, scaleFactor: CGFloat) { - print("[DisplayStreamRenderer] configure called - displayID: \(displayID), resolution: \(resolution), scaleFactor: \(scaleFactor)") - // Skip if already configured with same parameters if displayID == currentDisplayID, resolution == currentResolution, scaleFactor == currentScaleFactor, displayStream != nil { - print("[DisplayStreamRenderer] Already configured, skipping") return } @@ -57,14 +57,12 @@ final class DisplayStreamRenderer: NSView { let outputWidth = Int(resolution.width * scaleFactor) let outputHeight = Int(resolution.height * scaleFactor) - print("[DisplayStreamRenderer] Creating stream - outputWidth: \(outputWidth), outputHeight: \(outputHeight)") - // Create new display stream let stream = CGDisplayStream( dispatchQueueDisplay: displayID, outputWidth: outputWidth, outputHeight: outputHeight, - pixelFormat: pixelFormat, + pixelFormat: Constants.bgraPixelFormat, properties: [ CGDisplayStream.showCursor: true, ] as CFDictionary, @@ -78,9 +76,6 @@ final class DisplayStreamRenderer: NSView { if let stream = stream { displayStream = stream stream.start() - print("[DisplayStreamRenderer] Stream started successfully") - } else { - print("[DisplayStreamRenderer] ERROR: Failed to create CGDisplayStream!") } } diff --git a/DeskPad/Core/MouseTracker.swift b/DeskPad/Core/MouseTracker.swift index f807706..2fe54b4 100644 --- a/DeskPad/Core/MouseTracker.swift +++ b/DeskPad/Core/MouseTracker.swift @@ -1,7 +1,8 @@ +import Cocoa import Combine -import Foundation /// Tracks mouse position and handles cursor movement +@MainActor final class MouseTracker: ObservableObject { // MARK: - Published State @@ -9,16 +10,21 @@ final class MouseTracker: ObservableObject { // MARK: - Private Properties - private var timer: Timer? + private var timerSubscription: AnyCancellable? private var displayID: CGDirectDisplayID? - private let trackingInterval: TimeInterval = 0.25 + + // MARK: - Constants + + private enum Constants { + static let trackingInterval: TimeInterval = 0.25 + } // MARK: - Initialization init() {} deinit { - stopTracking() + timerSubscription?.cancel() } // MARK: - Public Methods @@ -27,24 +33,26 @@ final class MouseTracker: ObservableObject { func startTracking(displayID: CGDirectDisplayID) { self.displayID = displayID - // Stop any existing timer + // Stop any existing subscription stopTracking() - // Start polling timer - timer = Timer.scheduledTimer(withTimeInterval: trackingInterval, repeats: true) { [weak self] _ in - self?.updateMouseLocation() - } + // Start polling timer using Combine + timerSubscription = Timer.publish(every: Constants.trackingInterval, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.updateMouseLocation() + } } /// Stops tracking mouse position func stopTracking() { - timer?.invalidate() - timer = nil + timerSubscription?.cancel() + timerSubscription = nil } /// Moves the cursor to the specified point on the virtual display - func moveCursor(to point: NSPoint) { - guard let displayID = displayID else { return } + nonisolated func moveCursor(to point: NSPoint) { + guard let displayID = MainActor.assumeIsolated({ self.displayID }) else { return } CGDisplayMoveCursorToPoint(displayID, point) } diff --git a/DeskPad/Core/VirtualDisplayManager.swift b/DeskPad/Core/VirtualDisplayManager.swift index c9f81b2..bf94f31 100644 --- a/DeskPad/Core/VirtualDisplayManager.swift +++ b/DeskPad/Core/VirtualDisplayManager.swift @@ -1,7 +1,8 @@ +import Cocoa import Combine -import Foundation /// Manages the lifecycle of the virtual display +@MainActor final class VirtualDisplayManager: ObservableObject { // MARK: - Published State @@ -13,9 +14,9 @@ final class VirtualDisplayManager: ObservableObject { // MARK: - Private Properties private var virtualDisplay: CGVirtualDisplay? - private var notificationObserver: NSObjectProtocol? + private var screenChangeSubscription: AnyCancellable? + private var retrySubscription: AnyCancellable? private var retryCount = 0 - private let maxRetries = 50 // 5 seconds max at 100ms intervals // MARK: - Constants @@ -27,6 +28,8 @@ final class VirtualDisplayManager: ObservableObject { static let productID: UInt32 = 0x1234 static let serialNum: UInt32 = 0x0001 static let refreshRate: CGFloat = 60 + static let maxRetries = 50 + static let retryInterval: TimeInterval = 0.1 static let displayModes: [CGVirtualDisplayMode] = [ // 16:9 aspect ratio @@ -47,24 +50,18 @@ final class VirtualDisplayManager: ObservableObject { // MARK: - Initialization - init() { - print("[VirtualDisplayManager] Initialized") - } + init() {} deinit { - destroy() + screenChangeSubscription?.cancel() + retrySubscription?.cancel() } // MARK: - Public Methods /// Creates and configures the virtual display func create() { - guard virtualDisplay == nil else { - print("[VirtualDisplayManager] Already created") - return - } - - print("[VirtualDisplayManager] Creating virtual display...") + guard virtualDisplay == nil else { return } // Create descriptor let descriptor = CGVirtualDisplayDescriptor() @@ -82,30 +79,31 @@ final class VirtualDisplayManager: ObservableObject { virtualDisplay = display displayID = display.displayID - print("[VirtualDisplayManager] Display created with ID: \(display.displayID)") - // Configure settings let settings = CGVirtualDisplaySettings() settings.hiDPI = 1 settings.modes = Constants.displayModes display.apply(settings) - print("[VirtualDisplayManager] Settings applied") - - // Start observing screen parameter changes - startObservingScreenChanges() + // Start observing screen parameter changes using Combine + screenChangeSubscription = NotificationCenter.default + .publisher(for: NSApplication.didChangeScreenParametersNotification, object: NSApplication.shared) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateScreenConfiguration() + } - // Query initial configuration after a brief delay + // Start retry loop to find the display retryCount = 0 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.updateScreenConfiguration() - } + startRetryLoop() } /// Destroys the virtual display func destroy() { - print("[VirtualDisplayManager] Destroying...") - stopObservingScreenChanges() + screenChangeSubscription?.cancel() + screenChangeSubscription = nil + retrySubscription?.cancel() + retrySubscription = nil virtualDisplay = nil displayID = nil resolution = .zero @@ -115,53 +113,29 @@ final class VirtualDisplayManager: ObservableObject { // MARK: - Private Methods - private func startObservingScreenChanges() { - print("[VirtualDisplayManager] Starting notification observer") - notificationObserver = NotificationCenter.default.addObserver( - forName: NSApplication.didChangeScreenParametersNotification, - object: NSApplication.shared, - queue: .main - ) { [weak self] _ in - print("[VirtualDisplayManager] Received screen parameters notification") - self?.updateScreenConfiguration() - } - } - - private func stopObservingScreenChanges() { - if let observer = notificationObserver { - NotificationCenter.default.removeObserver(observer) - notificationObserver = nil - } + private func startRetryLoop() { + retrySubscription = Timer.publish(every: Constants.retryInterval, on: .main, in: .common) + .autoconnect() + .prefix(Constants.maxRetries) + .sink { [weak self] _ in + self?.updateScreenConfiguration() + } } private func updateScreenConfiguration() { - guard let displayID = displayID else { - print("[VirtualDisplayManager] No displayID") - return - } - - print("[VirtualDisplayManager] Looking for screen with displayID: \(displayID)") - print("[VirtualDisplayManager] Available screens: \(NSScreen.screens.map { "\($0.displayID)" }.joined(separator: ", "))") + guard let displayID = displayID else { return } guard let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) else { retryCount += 1 - if retryCount < maxRetries { - print("[VirtualDisplayManager] Display not found, retry \(retryCount)/\(maxRetries)") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.updateScreenConfiguration() - } - } else { - print("[VirtualDisplayManager] Max retries reached, display not found") - } return } - print("[VirtualDisplayManager] Found screen: \(screen.frame.size), scale: \(screen.backingScaleFactor)") + // Found the screen - stop retry loop + retrySubscription?.cancel() + retrySubscription = nil resolution = screen.frame.size scaleFactor = screen.backingScaleFactor isReady = true - - print("[VirtualDisplayManager] isReady = true") } }