diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..70eae28 Binary files /dev/null and b/.DS_Store differ diff --git a/DeskPad.xcodeproj/project.pbxproj b/DeskPad.xcodeproj/project.pbxproj index 98777f5..524aac1 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 */; }; @@ -20,11 +24,15 @@ 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 */; }; - 6DC04461280191EB00281728 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC04460280191EB00281728 /* main.swift */; }; /* 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 = ""; }; @@ -40,9 +48,9 @@ 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 = ""; }; - 6DC04460280191EB00281728 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -112,6 +120,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 = ( @@ -131,9 +150,10 @@ 6DC044502801877F00281728 /* DeskPad */ = { isa = PBXGroup; children = ( - 6DC04460280191EB00281728 /* main.swift */, + COREGRP280C211E00A3A2E5 /* Core */, 6D2F1485280C20C800A3A2E5 /* SubscriberViewController.swift */, 6DC044512801877F00281728 /* AppDelegate.swift */, + MAIN002280C211E00A3A2E5 /* main.swift */, 6D2F1483280C201B00A3A2E5 /* Backend */, 6D2F1484280C202700A3A2E5 /* Frontend */, 6D68E1B0287ABDAB00CD574A /* Helpers */, @@ -177,7 +197,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1320; - LastUpgradeCheck = 1320; + LastUpgradeCheck = 2620; TargetAttributes = { 6DC0444D2801877F00281728 = { CreatedOnToolsVersion = 13.2.1; @@ -219,6 +239,7 @@ /* Begin PBXShellScriptBuildPhase section */ 6D2F147F280C1C1400A3A2E5 /* Run SwiftFormat */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -242,6 +263,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 */, @@ -254,7 +279,7 @@ 6D41B0A42879FBA8007CEB2F /* ScreenViewData.swift in Sources */, 6D2F148E280C211E00A3A2E5 /* ScreenViewController.swift in Sources */, 6DC044522801877F00281728 /* AppDelegate.swift in Sources */, - 6DC04461280191EB00281728 /* main.swift in Sources */, + MAIN001280C211E00A3A2E5 /* main.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -265,6 +290,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"; @@ -295,9 +321,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 = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -317,6 +346,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"; @@ -327,6 +357,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"; @@ -357,9 +388,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 = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -372,6 +406,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"; @@ -387,8 +422,10 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 6; - DEVELOPMENT_TEAM = TYPC962S4N; + 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 = ""; @@ -397,7 +434,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; @@ -413,8 +450,10 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 6; - DEVELOPMENT_TEAM = TYPC962S4N; + 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 = ""; @@ -423,7 +462,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; 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/AppDelegate.swift b/DeskPad/AppDelegate.swift index 8e044cc..21f7b14 100644 --- a/DeskPad/AppDelegate.swift +++ b/DeskPad/AppDelegate.swift @@ -1,45 +1,80 @@ import Cocoa -import ReSwift -enum AppDelegateAction: Action { - case didFinishLaunching -} +final class AppDelegate: NSObject, NSApplicationDelegate { + // MARK: - Properties + + private var window: NSWindow! + private var viewController: DeskPadViewController! -class AppDelegate: NSObject, NSApplicationDelegate { - var window: NSWindow! + // 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) { - let viewController = ScreenViewController() + // Create view controller (which creates display in viewDidLoad) + viewController = DeskPadViewController() + + // 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 + } + + func applicationSupportsSecureRestorableState(_: NSApplication) -> Bool { + true + } + + // MARK: - Window Configuration + + private func configureWindow() { + 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) + } + + // 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: Constants.windowTitle) + + let quitItem = NSMenuItem( + title: "Quit \(Constants.windowTitle)", 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/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..5fe262f 100644 --- a/DeskPad/Backend/MouseLocation/MouseLocationSideEffect.swift +++ b/DeskPad/Backend/MouseLocation/MouseLocationSideEffect.swift @@ -14,19 +14,17 @@ func mouseLocationSideEffect() -> SideEffect { 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 screenContainingMouse = screens.first { NSMouseInRect(mouseLocation, $0.frame, false) } let isWithinScreen = screenContainingMouse?.displayID == getState()?.screenConfigurationState.displayID dispatch(MouseLocationAction.located(isWithinScreen: isWithinScreen)) } } - switch action { - case let MouseLocationAction.requestMove(point): + + if case let MouseLocationAction.requestMove(point) = action { guard let displayID = getState()?.screenConfigurationState.displayID else { return } CGDisplayMoveCursorToPoint(displayID, point) - default: - return } } } 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/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/Core/DeskPadViewController.swift b/DeskPad/Core/DeskPadViewController.swift new file mode 100644 index 0000000..e87a677 --- /dev/null +++ b/DeskPad/Core/DeskPadViewController.swift @@ -0,0 +1,181 @@ +import Cocoa +import Combine + +/// Main view controller for the DeskPad display window +@MainActor +final class DeskPadViewController: NSViewController, NSWindowDelegate { + // MARK: - Properties + + private var displayManager: VirtualDisplayManager! + private var mouseTracker: MouseTracker! + private var renderer: DisplayStreamRenderer! + private var cancellables = Set() + private var isWindowHighlighted = false + + // MARK: - Constants + + private enum Constants { + static let windowSnappingThreshold: CGFloat = 30 + static let initialFrameSize = NSRect(x: 0, y: 0, width: 1280, height: 720) + } + + // MARK: - Initialization + + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func loadView() { + renderer = DisplayStreamRenderer(frame: Constants.initialFrameSize) + view = renderer + + // Add click gesture + let clickGesture = NSClickGestureRecognizer(target: self, action: #selector(handleClick(_:))) + view.addGestureRecognizer(clickGesture) + } + + override func viewDidLoad() { + super.viewDidLoad() + + // Create managers + displayManager = VirtualDisplayManager() + mouseTracker = MouseTracker() + + // Set up bindings for state changes + setupBindings() + + // Create virtual display immediately + displayManager.create() + } + + override func viewDidDisappear() { + super.viewDidDisappear() + renderer.stopStream() + mouseTracker.stopTracking() + displayManager.destroy() + } + + // MARK: - Bindings + + private func setupBindings() { + // When display becomes ready, configure everything + displayManager.$isReady + .receive(on: DispatchQueue.main) + .sink { [weak self] isReady in + guard isReady else { return } + self?.onDisplayReady() + } + .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) + + // 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 onDisplayReady() { + guard let displayID = displayManager.displayID else { return } + + let resolution = displayManager.resolution + let scaleFactor = displayManager.scaleFactor + + // Configure window size + if let window = view.window, resolution != .zero { + window.setContentSize(resolution) + window.contentAspectRatio = resolution + window.center() + } + + // Start streaming + if resolution != .zero { + renderer.configure(displayID: displayID, resolution: resolution, scaleFactor: scaleFactor) + } + + // Start mouse tracking + mouseTracker.startTracking(displayID: displayID) + } + + 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 + } + + // Reconfigure stream + renderer.configure( + displayID: displayID, + resolution: resolution, + scaleFactor: displayManager.scaleFactor + ) + } + + // 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 + + 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 + } + + return frameSize + } + } +} diff --git a/DeskPad/Core/DisplayStreamRenderer.swift b/DeskPad/Core/DisplayStreamRenderer.swift new file mode 100644 index 0000000..7173faf --- /dev/null +++ b/DeskPad/Core/DisplayStreamRenderer.swift @@ -0,0 +1,103 @@ +import Cocoa + +/// Simple layer-based renderer for the display stream +final class DisplayStreamRenderer: NSView { + // MARK: - Properties + + private var displayStream: CGDisplayStream? + private var currentDisplayID: CGDirectDisplayID? + private var currentResolution: CGSize = .zero + private var currentScaleFactor: CGFloat = 1.0 + + // 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 + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + stopStream() + } + + // 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: Constants.bgraPixelFormat, + properties: [ + CGDisplayStream.showCursor: true, + ] as CFDictionary, + queue: .main, + handler: { [weak self] _, _, frameSurface, _ in + guard let surface = frameSurface else { return } + self?.layer?.contents = surface + } + ) + + if let stream = stream { + displayStream = stream + stream.start() + } + } + + /// Stops the display stream + func stopStream() { + displayStream?.stop() + displayStream = nil + } + + // 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..2fe54b4 --- /dev/null +++ b/DeskPad/Core/MouseTracker.swift @@ -0,0 +1,78 @@ +import Cocoa +import Combine + +/// Tracks mouse position and handles cursor movement +@MainActor +final class MouseTracker: ObservableObject { + // MARK: - Published State + + @Published private(set) var isWithinVirtualDisplay = false + + // MARK: - Private Properties + + private var timerSubscription: AnyCancellable? + private var displayID: CGDirectDisplayID? + + // MARK: - Constants + + private enum Constants { + static let trackingInterval: TimeInterval = 0.25 + } + + // MARK: - Initialization + + init() {} + + deinit { + timerSubscription?.cancel() + } + + // MARK: - Public Methods + + /// Starts tracking mouse position relative to the virtual display + func startTracking(displayID: CGDirectDisplayID) { + self.displayID = displayID + + // Stop any existing subscription + stopTracking() + + // 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() { + timerSubscription?.cancel() + timerSubscription = nil + } + + /// Moves the cursor to the specified point on the virtual display + nonisolated func moveCursor(to point: NSPoint) { + guard let displayID = MainActor.assumeIsolated({ self.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..bf94f31 --- /dev/null +++ b/DeskPad/Core/VirtualDisplayManager.swift @@ -0,0 +1,141 @@ +import Cocoa +import Combine + +/// Manages the lifecycle of the virtual display +@MainActor +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 screenChangeSubscription: AnyCancellable? + private var retrySubscription: AnyCancellable? + private var retryCount = 0 + + // 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 maxRetries = 50 + static let retryInterval: TimeInterval = 0.1 + + 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 { + screenChangeSubscription?.cancel() + retrySubscription?.cancel() + } + + // 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) + virtualDisplay = display + displayID = display.displayID + + // Configure settings + let settings = CGVirtualDisplaySettings() + settings.hiDPI = 1 + settings.modes = Constants.displayModes + display.apply(settings) + + // 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() + } + + // Start retry loop to find the display + retryCount = 0 + startRetryLoop() + } + + /// Destroys the virtual display + func destroy() { + screenChangeSubscription?.cancel() + screenChangeSubscription = nil + retrySubscription?.cancel() + retrySubscription = nil + virtualDisplay = nil + displayID = nil + resolution = .zero + scaleFactor = 1.0 + isReady = false + } + + // MARK: - Private Methods + + 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 { return } + + guard let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) else { + retryCount += 1 + return + } + + // Found the screen - stop retry loop + retrySubscription?.cancel() + retrySubscription = nil + + resolution = screen.frame.size + scaleFactor = screen.backingScaleFactor + isReady = true + } +} 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 diff --git a/DeskPad/Frontend/Screen/ScreenViewController.swift b/DeskPad/Frontend/Screen/ScreenViewController.swift index b47186c..1adc41f 100644 --- a/DeskPad/Frontend/Screen/ScreenViewController.swift +++ b/DeskPad/Frontend/Screen/ScreenViewController.swift @@ -5,7 +5,40 @@ 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), + ] +} + +final class ScreenViewController: SubscriberViewController, NSWindowDelegate { override func loadView() { view = NSView() view.wantsLayer = true @@ -20,16 +53,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 +73,100 @@ 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() } + } - 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() + private func updateDisplayStream(resolution: CGSize, scaleFactor: CGFloat) { + guard resolution != .zero, + resolution != previousResolution || scaleFactor != previousScaleFactor + else { + return } + + 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() } func windowWillResize(_ window: NSWindow, to frameSize: NSSize) -> NSSize { - let snappingOffset: CGFloat = 30 + calculateResizedFrame(for: window, proposedSize: frameSize) + } + + 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..9dfa864 100644 --- a/DeskPad/SubscriberViewController.swift +++ b/DeskPad/SubscriberViewController.swift @@ -21,13 +21,16 @@ class SubscriberViewController: NSViewController, StoreS } 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 - self?.update(with: ViewData(for: state)) + 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.") } }