diff --git a/.gitignore b/.gitignore index a5dc678..e49e0f6 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ fastlane/screenshots Documentation/docs /Fluor/Sparkle.framework /Fluor/Sparkle.framework.dSYM +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c5efb69 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,74 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build Commands + +```bash +# Build (requires Xcode; use DEVELOPER_DIR if xcode-select points to CommandLineTools) +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -scheme Fluor -configuration Debug build + +# Build without code signing (for development without matching certificates) +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -scheme Fluor -configuration Debug build CODE_SIGN_IDENTITY="-" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO +``` + +There are no tests in this project. The project uses Xcode (not Swift Package Manager) as its build system. + +## Architecture + +Fluor is a macOS status bar app (Swift 5 / AppKit) that switches the keyboard's fn-key behavior (media keys vs F1-F12) based on the active application. + +### Core Flow + +``` +AppDelegate → StatusMenuController (status bar item, Main.xib) + ├── BehaviorController — monitors active app, switches fn-key mode via FKeyManager + ├── MenuItemsController — manages menu UI (embeds 3 child ViewControllers) + └── Window Controllers — lazy-loaded via StoryboardInstantiable protocol + ├── PreferencesWindowController (Preferences.storyboard) + ├── RulesEditorWindowController (RulesEditor.storyboard) + ├── RunningAppWindowController (RunningApps.storyboard) + └── AboutWindowController (About.storyboard) +``` + +### Key Components + +- **FKeyManager** (`Misc/FKeyManager.swift`) — IOKit interface that reads/writes the fn-key mode via `IOHIDSetCFTypeParameter` and `IORegistryEntryCreateCFProperty`. Requires Accessibility permissions. +- **BehaviorController** (`Controllers/BehaviorController.swift`) — Core logic: listens to NSWorkspace active-app changes, determines target FKeyMode from stored rules, and calls FKeyManager. Also handles Fn-key press detection for hybrid/key switch methods. +- **AppManager** (`Models/AppManager.swift`) — Singleton storing all persistent state via `@Defaults` property wrappers (from DefaultsWrapper SPM package). Holds the rule set, default mode, switch method, UI preferences. +- **StatusMenuController** (`Controllers/StatusMenuController.swift`) — Owns the NSStatusItem, delegates to BehaviorController and MenuItemsController, manages window controller lifecycle. + +### Communication Pattern + +Components communicate via paired Notification observer/poster protocols defined in `Protocols/NotificationHelpers.swift`: +- `BehaviorDidChange` — fn-key behavior changed for an app +- `SwitchMethodDidChange` — user changed switch method (window/hybrid/key) +- `MenuControlObserver/Poster` — menu open/close coordination + +### Three Switch Methods (enum `SwitchMethod`) + +1. **Window** — auto-switch based on frontmost app's stored rule +2. **Hybrid** — window mode + Fn-key press toggles current app's behavior +3. **Key** — Fn-key press toggles global default mode only + +### Objective-C Interop + +Bridged via `Fluor-Bridging-Header.h`: +- **LaunchAtLoginController** — manages login item registration +- **PFMoveApplication** — prompts to move app to /Applications (RELEASE builds only) + +### SPM Dependencies + +- **DefaultsWrapper** — `@Defaults` property wrapper for typed UserDefaults access +- **Sparkle** (v2.x) — auto-update framework (bindings go through `self.updater.*` key paths in Preferences.storyboard) +- **CoreGeometry** / **SmoothOperators** — geometry utilities and operator extensions + +## Gotchas + +- **KVO + `@objc let` properties**: Storyboard bindings using nested key paths through `@objc let` stored properties (e.g., `objectValue.url.path` where `url` is `let`) crash on modern macOS — KVO can't create an ivar setter for constants. Use a `@objc dynamic var` computed wrapper instead, or avoid nested paths through `let` properties. +- **Window display pattern**: Use `makeKeyAndOrderFront(self)` + `makeMain()` + `NSApp.activate(ignoringOtherApps:)` to show windows. Do not use `orderFrontRegardless()`. +- **Storyboard debugging**: When investigating storyboard-related crashes, always request the crash backtrace — code review alone is insufficient for KVO/binding issues. + +## Commit Guidelines + +- Do not include "Co-Authored-By" lines or any Claude/AI attribution in commit messages. diff --git a/Fluor.xcodeproj/project.pbxproj b/Fluor.xcodeproj/project.pbxproj index e9e7280..0249687 100644 --- a/Fluor.xcodeproj/project.pbxproj +++ b/Fluor.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -42,8 +42,7 @@ 3F8F93BB1EEAC9B900FCE91F /* RuleCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8F93BA1EEAC9B900FCE91F /* RuleCellView.swift */; }; 3F8F93BD1EEAF1EB00FCE91F /* RuleValueTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8F93BC1EEAF1EB00FCE91F /* RuleValueTransformer.swift */; }; 3F9EDD2A245C7BAF0047D1AC /* MenuItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F9EDD29245C7BAF0047D1AC /* MenuItemView.swift */; }; - 3FBE4C262222A3C600782647 /* Sparkle.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3FBE4C202222A22200782647 /* Sparkle.framework */; }; - 3FBE4C272222A3C600782647 /* Sparkle.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3FBE4C202222A22200782647 /* Sparkle.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 3FBE4C262222A3C600782647 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 3FBE4C302222A3C600782647 /* Sparkle */; }; 3FBE4C292222AB0200782647 /* dsa_pub.pem in Resources */ = {isa = PBXBuildFile; fileRef = 3FBE4C282222AB0200782647 /* dsa_pub.pem */; }; 3FC44EFA1D7F169A0065D433 /* Enums.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC44EF91D7F169A0065D433 /* Enums.swift */; }; 3FC44EFC1D7F16CB0065D433 /* Items.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC44EFB1D7F16CB0065D433 /* Items.swift */; }; @@ -68,20 +67,6 @@ 3FF655CA20751D1600C8D2FC /* PFMoveApplication.m in Sources */ = {isa = PBXBuildFile; fileRef = 3FF655C820751D1600C8D2FC /* PFMoveApplication.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; /* End PBXBuildFile section */ -/* Begin PBXCopyFilesBuildPhase section */ - 3FBE4C252222A25200782647 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - 3FBE4C272222A3C600782647 /* Sparkle.framework in Embed Frameworks */, - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - /* Begin PBXFileReference section */ 3DF3612324C206AD00231BF5 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/MainMenu.strings"; sourceTree = ""; }; 3DF3612424C206AD00231BF5 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/RunningApps.strings"; sourceTree = ""; }; @@ -126,7 +111,6 @@ 3F8F93BA1EEAC9B900FCE91F /* RuleCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RuleCellView.swift; sourceTree = ""; }; 3F8F93BC1EEAF1EB00FCE91F /* RuleValueTransformer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RuleValueTransformer.swift; sourceTree = ""; }; 3F9EDD29245C7BAF0047D1AC /* MenuItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemView.swift; sourceTree = ""; }; - 3FBE4C202222A22200782647 /* Sparkle.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Sparkle.framework; path = ../../../../pyroh/Dev/Projects/Fluor/Fluor/Sparkle.framework; sourceTree = ""; }; 3FBE4C282222AB0200782647 /* dsa_pub.pem */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = dsa_pub.pem; sourceTree = ""; }; 3FC44EF91D7F169A0065D433 /* Enums.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Enums.swift; sourceTree = ""; }; 3FC44EFB1D7F16CB0065D433 /* Items.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Items.swift; sourceTree = ""; }; @@ -165,7 +149,7 @@ files = ( 3FE7B6DB245DF8450027DB39 /* SmoothOperators in Frameworks */, 3F16ECDD23E9D1AC008BC89A /* DefaultsWrapper in Frameworks */, - 3FBE4C262222A3C600782647 /* Sparkle.framework in Frameworks */, + 3FBE4C262222A3C600782647 /* Sparkle in Frameworks */, 3F15629F23FEDD0000CD0773 /* CoreGeometry in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -279,7 +263,6 @@ 3F7291EC2025BD1E005C9B70 /* Frameworks */ = { isa = PBXGroup; children = ( - 3FBE4C202222A22200782647 /* Sparkle.framework */, ); name = Frameworks; sourceTree = ""; @@ -347,7 +330,6 @@ 3FCD169E1D79793600C57B22 /* Frameworks */, 3FCD169F1D79793600C57B22 /* Resources */, 3FD11B2F207A320800742415 /* Run Script */, - 3FBE4C252222A25200782647 /* Embed Frameworks */, ); buildRules = ( ); @@ -358,6 +340,7 @@ 3F16ECDC23E9D1AC008BC89A /* DefaultsWrapper */, 3F15629E23FEDD0000CD0773 /* CoreGeometry */, 3FE7B6DA245DF8450027DB39 /* SmoothOperators */, + 3FBE4C302222A3C600782647 /* Sparkle */, ); productName = Fluor; productReference = 3FCD16A11D79793600C57B22 /* Fluor.app */; @@ -403,6 +386,7 @@ 3F16ECDB23E9D1AC008BC89A /* XCRemoteSwiftPackageReference "DefaultsWrapper" */, 3F15629D23FEDD0000CD0773 /* XCRemoteSwiftPackageReference "CoreGeometry" */, 3FE7B6D9245DF8450027DB39 /* XCRemoteSwiftPackageReference "SmoothOperators" */, + 3FBE4C2F2222A3C600782647 /* XCRemoteSwiftPackageReference "Sparkle" */, ); productRefGroup = 3FCD16A21D79793600C57B22 /* Products */; projectDirPath = ""; @@ -626,7 +610,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.12; + MACOSX_DEPLOYMENT_TARGET = 10.13; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -680,7 +664,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.12; + MACOSX_DEPLOYMENT_TARGET = 10.13; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -699,18 +683,15 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 5; - DEVELOPMENT_TEAM = Q2E884V952; + DEVELOPMENT_TEAM = 87NLB3L2H3; ENABLE_HARDENED_RUNTIME = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Fluor", - ); + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Fluor/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.12; + MACOSX_DEPLOYMENT_TARGET = 10.13; MARKETING_VERSION = 2.5.0; PRODUCT_BUNDLE_IDENTIFIER = com.pyrolyse.Fluor; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -733,18 +714,15 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 5; - DEVELOPMENT_TEAM = Q2E884V952; + DEVELOPMENT_TEAM = 87NLB3L2H3; ENABLE_HARDENED_RUNTIME = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Fluor", - ); + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Fluor/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.12; + MACOSX_DEPLOYMENT_TARGET = 10.13; MARKETING_VERSION = 2.5.0; OTHER_SWIFT_FLAGS = "-DRELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.pyrolyse.Fluor; @@ -798,6 +776,14 @@ minimumVersion = 1.1.0; }; }; + 3FBE4C2F2222A3C600782647 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; 3FE7B6D9245DF8450027DB39 /* XCRemoteSwiftPackageReference "SmoothOperators" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Pyroh/SmoothOperators.git"; @@ -819,6 +805,11 @@ package = 3F16ECDB23E9D1AC008BC89A /* XCRemoteSwiftPackageReference "DefaultsWrapper" */; productName = DefaultsWrapper; }; + 3FBE4C302222A3C600782647 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 3FBE4C2F2222A3C600782647 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; 3FE7B6DA245DF8450027DB39 /* SmoothOperators */ = { isa = XCSwiftPackageProductDependency; package = 3FE7B6D9245DF8450027DB39 /* XCRemoteSwiftPackageReference "SmoothOperators" */; diff --git a/Fluor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Fluor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d2f1425..84a7c85 100644 --- a/Fluor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Fluor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,34 +1,42 @@ { - "object": { - "pins": [ - { - "package": "CoreGeometry", - "repositoryURL": "https://gitlab.com/Pyroh/CoreGeometry.git", - "state": { - "branch": null, - "revision": "a59affa331972263980f8a2af4cfd5f37ad42d3d", - "version": "3.0.0" - } - }, - { - "package": "DefaultsWrapper", - "repositoryURL": "https://github.com/Pyroh/DefaultsWrapper.git", - "state": { - "branch": null, - "revision": "649794e5c7af45f69d1e128a3c9fd0fe1cde282e", - "version": "1.2.0" - } - }, - { - "package": "SmoothOperators", - "repositoryURL": "https://github.com/Pyroh/SmoothOperators.git", - "state": { - "branch": null, - "revision": "12f0d7cc9ade6bc826a822f9d2021b4dd394c4a0", - "version": "0.4.0" - } + "originHash" : "df83e47aff8db5afda649e64a1bb32af88bfc9a2ea94edfe3589bf4f1478a06f", + "pins" : [ + { + "identity" : "coregeometry", + "kind" : "remoteSourceControl", + "location" : "https://gitlab.com/Pyroh/CoreGeometry.git", + "state" : { + "revision" : "a59affa331972263980f8a2af4cfd5f37ad42d3d", + "version" : "3.0.0" } - ] - }, - "version": 1 + }, + { + "identity" : "defaultswrapper", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Pyroh/DefaultsWrapper.git", + "state" : { + "revision" : "649794e5c7af45f69d1e128a3c9fd0fe1cde282e", + "version" : "1.2.0" + } + }, + { + "identity" : "smoothoperators", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Pyroh/SmoothOperators.git", + "state" : { + "revision" : "12f0d7cc9ade6bc826a822f9d2021b4dd394c4a0", + "version" : "0.4.0" + } + }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle.git", + "state" : { + "revision" : "21d8df80440b1ca3b65fa82e40782f1e5a9e6ba2", + "version" : "2.9.0" + } + } + ], + "version" : 3 } diff --git a/Fluor.xcodeproj/xcshareddata/xcschemes/Fluor.xcscheme b/Fluor.xcodeproj/xcshareddata/xcschemes/Fluor.xcscheme index bf0f284..591742c 100644 --- a/Fluor.xcodeproj/xcshareddata/xcschemes/Fluor.xcscheme +++ b/Fluor.xcodeproj/xcshareddata/xcschemes/Fluor.xcscheme @@ -1,28 +1,10 @@ + version = "1.3"> - - - - - - - - - - - - - - - - - - - - diff --git a/Fluor/Base.lproj/Preferences.storyboard b/Fluor/Base.lproj/Preferences.storyboard index 99a6830..0093775 100644 --- a/Fluor/Base.lproj/Preferences.storyboard +++ b/Fluor/Base.lproj/Preferences.storyboard @@ -220,7 +220,6 @@ - @@ -420,7 +419,7 @@ - + @@ -433,7 +432,7 @@ - + @@ -470,7 +469,7 @@ - + @@ -532,7 +531,7 @@ - + diff --git a/Fluor/Base.lproj/RulesEditor.storyboard b/Fluor/Base.lproj/RulesEditor.storyboard index 25508e5..87e3e12 100644 --- a/Fluor/Base.lproj/RulesEditor.storyboard +++ b/Fluor/Base.lproj/RulesEditor.storyboard @@ -10,7 +10,7 @@ - + @@ -104,7 +104,6 @@ - @@ -173,9 +172,6 @@ - - - diff --git a/Fluor/Base.lproj/RunningApps.storyboard b/Fluor/Base.lproj/RunningApps.storyboard index 71c4f5c..a086447 100644 --- a/Fluor/Base.lproj/RunningApps.storyboard +++ b/Fluor/Base.lproj/RunningApps.storyboard @@ -10,7 +10,7 @@ - + diff --git a/Fluor/Controllers/StatusMenuController.swift b/Fluor/Controllers/StatusMenuController.swift index 8a1a3d0..d059ec9 100644 --- a/Fluor/Controllers/StatusMenuController.swift +++ b/Fluor/Controllers/StatusMenuController.swift @@ -137,12 +137,16 @@ class StatusMenuController: NSObject, NSMenuDelegate, NSWindowDelegate, MenuCont /// - parameter sender: The object that sent the action. @IBAction func editRules(_ sender: AnyObject) { guard rulesController == nil else { - rulesController?.window?.orderFrontRegardless() + rulesController?.window?.makeKeyAndOrderFront(self) + rulesController?.window?.makeMain() + NSApp.activate(ignoringOtherApps: true) return } rulesController = RulesEditorWindowController.instantiate() rulesController?.window?.delegate = self - rulesController?.window?.orderFrontRegardless() + rulesController?.window?.makeKeyAndOrderFront(self) + rulesController?.window?.makeMain() + NSApp.activate(ignoringOtherApps: true) } /// Show the *About* window. @@ -184,12 +188,16 @@ class StatusMenuController: NSObject, NSMenuDelegate, NSWindowDelegate, MenuCont /// - parameter sender: The object that sent the action. @IBAction func showRunningApps(_ sender: AnyObject) { guard runningAppsController == nil else { - runningAppsController?.window?.orderFrontRegardless() + runningAppsController?.window?.makeKeyAndOrderFront(self) + runningAppsController?.window?.makeMain() + NSApp.activate(ignoringOtherApps: true) return } runningAppsController = RunningAppWindowController.instantiate() runningAppsController?.window?.delegate = self - runningAppsController?.window?.orderFrontRegardless() + runningAppsController?.window?.makeKeyAndOrderFront(self) + runningAppsController?.window?.makeMain() + NSApp.activate(ignoringOtherApps: true) } diff --git a/Fluor/Controllers/ViewControllers/RulesEditorViewController.swift b/Fluor/Controllers/ViewControllers/RulesEditorViewController.swift index 6d3300b..21e7bfc 100644 --- a/Fluor/Controllers/ViewControllers/RulesEditorViewController.swift +++ b/Fluor/Controllers/ViewControllers/RulesEditorViewController.swift @@ -50,10 +50,13 @@ class RulesEditorViewController: NSViewController, BehaviorDidChangeObserver { self.rulesSet = AppManager.default.rules + tableView.bind(.selectionIndexes, to: itemsArrayController!, withKeyPath: "selectionIndexes", options: nil) + self.tableContentAnimator = TableViewContentAnimator(tableView: tableView, arrayController: itemsArrayController) } - + deinit { + tableView.unbind(.selectionIndexes) stopObservingBehaviorDidChange() itemsArrayController.removeObserver(self, forKeyPath: "canRemove") itemsArrayController.removeObserver(self, forKeyPath: "canAdd") diff --git a/Fluor/Info.plist b/Fluor/Info.plist index 5a8ab56..8396660 100644 --- a/Fluor/Info.plist +++ b/Fluor/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion - 2345 + 2374 FLGithubURL https://github.com/Pyroh/Fluor FLPyrolyseURL