From ef02876690d45b47c93c336f59acd8c1b80bcf20 Mon Sep 17 00:00:00 2001 From: Shay Prasad Date: Sat, 12 Sep 2026 13:27:52 +0100 Subject: [PATCH] Fix "Settings could not be opened" after the LumaControl rename The gear button and reopening the app showed "Settings could not be opened - The app's preference panes failed to load from its interface file." Root cause: the rebrand changed PRODUCT_NAME to LumaControl and, in the same commit, pinned SWIFT_MODULE_NAME = XDRMonitorControl. Those two settings feed different parts of the build: * PRODUCT_MODULE_NAME follows PRODUCT_NAME, and that is the module ibtool bakes into the compiled storyboard, because every scene is declared with customModuleProvider="target". * SWIFT_MODULE_NAME is what the Swift compiler uses for -module-name, so the pane classes became XDRMonitorControl.MainPrefsViewController at runtime. The storyboard therefore asked for LumaControl.MainPrefsViewController, AppKit found no such class, logged "Unknown class ... in Interface Builder file", handed back a bare NSViewController, and the `as?` cast in main.swift produced nil for all five panes. AppDelegate dropped every pane and raised the alert. Dropping SWIFT_MODULE_NAME lets the Swift module follow PRODUCT_NAME again, so the two cannot drift apart. The only other reference to the old module name was the test target's `@testable import`. Adds MonitorControlTests/SettingsPanesTests.swift, which loads the five scenes exactly the way main.swift does and also compares each pane class's runtime module against the app's own name. Putting the pin back makes it fail with all five panes nil and the mismatched module name spelled out, so this cannot regress silently again. Verified: 14/14 tests pass; the compiled storyboard and the app binary now both name _TtC11LumaControl23MainPrefsViewController; the Settings window opens on the installed 0.3.0 (build 44) build. --- MonitorControl.xcodeproj/project.pbxproj | 6 +- MonitorControlTests/SettingsPanesTests.swift | 75 ++++++++++++++++++++ MonitorControlTests/XDRBrightnessTests.swift | 2 +- 3 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 MonitorControlTests/SettingsPanesTests.swift diff --git a/MonitorControl.xcodeproj/project.pbxproj b/MonitorControl.xcodeproj/project.pbxproj index 4f9029c..7c69fae 100644 --- a/MonitorControl.xcodeproj/project.pbxproj +++ b/MonitorControl.xcodeproj/project.pbxproj @@ -60,6 +60,7 @@ F0A489C4279C71B200BEDFD6 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A489C3279C71B200BEDFD6 /* OnboardingViewController.swift */; }; FE4E0896249D584C003A50BB /* OSDUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4E0895249D584C003A50BB /* OSDUtils.swift */; }; E77C0DE000000000000000A3 /* XDRBrightnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E77C0DE000000000000000A1 /* XDRBrightnessTests.swift */; }; + E77C0DE000000000000000B3 /* SettingsPanesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E77C0DE000000000000000B1 /* SettingsPanesTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -185,6 +186,7 @@ FB5DB2902AD54C4600306223 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; FE4E0895249D584C003A50BB /* OSDUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDUtils.swift; sourceTree = ""; }; E77C0DE000000000000000A1 /* XDRBrightnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDRBrightnessTests.swift; sourceTree = ""; }; + E77C0DE000000000000000B1 /* SettingsPanesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPanesTests.swift; sourceTree = ""; }; E77C0DE000000000000000A2 /* MonitorControlTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = MonitorControlTests.xctest; path = MonitorControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -390,6 +392,7 @@ isa = PBXGroup; children = ( E77C0DE000000000000000A1 /* XDRBrightnessTests.swift */, + E77C0DE000000000000000B1 /* SettingsPanesTests.swift */, ); path = MonitorControlTests; sourceTree = ""; @@ -745,6 +748,7 @@ buildActionMask = 2147483647; files = ( E77C0DE000000000000000A3 /* XDRBrightnessTests.swift in Sources */, + E77C0DE000000000000000B3 /* SettingsPanesTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -996,7 +1000,6 @@ PRODUCT_NAME = LumaControl; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "MonitorControl/Support/Bridging-Header.h"; - SWIFT_MODULE_NAME = XDRMonitorControl; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.5; SYSTEM_FRAMEWORK_SEARCH_PATHS = ( @@ -1035,7 +1038,6 @@ PRODUCT_NAME = LumaControl; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "MonitorControl/Support/Bridging-Header.h"; - SWIFT_MODULE_NAME = XDRMonitorControl; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.5; SYSTEM_FRAMEWORK_SEARCH_PATHS = ( diff --git a/MonitorControlTests/SettingsPanesTests.swift b/MonitorControlTests/SettingsPanesTests.swift new file mode 100644 index 0000000..0247980 --- /dev/null +++ b/MonitorControlTests/SettingsPanesTests.swift @@ -0,0 +1,75 @@ +// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others + +import Cocoa +import XCTest + +@testable import LumaControl + +// The Settings window is assembled at launch from five storyboard scenes, and when that +// goes wrong it goes wrong silently. +// +// `NSStoryboard.instantiateController(withIdentifier:)` does not raise when a scene's +// `customModule` names a Swift module that does not exist at runtime. AppKit only logs +// "Unknown class … in Interface Builder file" to the console, hands back a plain +// `NSViewController`, and the `as?` cast in `main.swift` yields nil. `AppDelegate` +// then drops every pane, so the gear button and reopening the app both show +// "Settings could not be opened" with nothing on screen to say why. +// +// That is exactly what happened when the app target was renamed to LumaControl. Every +// scene is declared `customModuleProvider="target"`, so ibtool bakes `PRODUCT_MODULE_NAME` +// into the compiled storyboard — and `PRODUCT_MODULE_NAME` follows `PRODUCT_NAME`, which +// had become `LumaControl`. The app target still carried an explicit +// `SWIFT_MODULE_NAME = XDRMonitorControl`, and that is the string the Swift compiler uses +// for `-module-name` and therefore for the runtime class names. The storyboard asked for +// `LumaControl.MainPrefsViewController`, the binary only had +// `XDRMonitorControl.MainPrefsViewController`, and all five panes vanished. +// +// These tests load the same scenes the same way `main.swift` does, so they fail loudly the +// moment the two names drift apart again. They need no display and touch no hardware. +final class SettingsPanesTests: XCTestCase { + func testEverySettingsPaneLoadsFromTheStoryboard() { + let storyboard = NSStoryboard(name: "Main", bundle: Bundle.main) + + let main = storyboard.instantiateController(withIdentifier: "MainPrefsVC") as? MainPrefsViewController + XCTAssertNotNil(main, "The MainPrefsVC scene did not load — see the note at the top of SettingsPanesTests.") + + let menusliders = storyboard.instantiateController(withIdentifier: "MenuslidersPrefsVC") as? MenuslidersPrefsViewController + XCTAssertNotNil(menusliders, "The MenuslidersPrefsVC scene did not load — see the note at the top of SettingsPanesTests.") + + let keyboard = storyboard.instantiateController(withIdentifier: "KeyboardPrefsVC") as? KeyboardPrefsViewController + XCTAssertNotNil(keyboard, "The KeyboardPrefsVC scene did not load — see the note at the top of SettingsPanesTests.") + + let displays = storyboard.instantiateController(withIdentifier: "DisplaysPrefsVC") as? DisplaysPrefsViewController + XCTAssertNotNil(displays, "The DisplaysPrefsVC scene did not load — see the note at the top of SettingsPanesTests.") + + let about = storyboard.instantiateController(withIdentifier: "AboutPrefsVC") as? AboutPrefsViewController + XCTAssertNotNil(about, "The AboutPrefsVC scene did not load — see the note at the top of SettingsPanesTests.") + } + + /// The storyboard resolves its scenes against `PRODUCT_MODULE_NAME`, and for an app + /// target that is `PRODUCT_NAME` — the same value `CFBundleName` carries. So the runtime + /// name of a pane class has to start with the app's own name. Comparing against + /// `CFBundleName` rather than a literal means a future rename updates this check for + /// free while still catching the case where the Swift module is pinned to an old name. + func testPaneClassesLiveInTheModuleTheStoryboardResolvesAgainst() { + let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + XCTAssertEqual(appName, "LumaControl", "CFBundleName should follow PRODUCT_NAME.") + + let paneClasses: [AnyClass] = [ + MainPrefsViewController.self, + MenuslidersPrefsViewController.self, + KeyboardPrefsViewController.self, + DisplaysPrefsViewController.self, + AboutPrefsViewController.self, + ] + + for paneClass in paneClasses { + let runtimeName = NSStringFromClass(paneClass) + XCTAssertEqual( + runtimeName.split(separator: ".").first.map(String.init), + appName, + "\(runtimeName) is compiled into a different module than the storyboard resolves against." + ) + } + } +} diff --git a/MonitorControlTests/XDRBrightnessTests.swift b/MonitorControlTests/XDRBrightnessTests.swift index bce33aa..0c4f55d 100644 --- a/MonitorControlTests/XDRBrightnessTests.swift +++ b/MonitorControlTests/XDRBrightnessTests.swift @@ -2,7 +2,7 @@ import XCTest -@testable import XDRMonitorControl +@testable import LumaControl // AppleDisplay.getBrightness() short-circuits to a constant for dummy displays, so // calcNewBrightness tests use this stub to drive the current brightness directly.