From 9c8b3b377b9e1e675d4d7b801d9643255f990b6e Mon Sep 17 00:00:00 2001 From: winebarrel Date: Mon, 10 Aug 2026 13:26:38 +0900 Subject: [PATCH] Put the configure sheet back, and document why Options dies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the sheet lifetime change. The reasoning behind it does not survive contact with the machine: the second read was never the failure, and neither was holding one controller. Reading the property twice does drop the first window, but hosts read it once per click — traced from inside the installed bundle — so nothing was being dropped in practice. The reuse it replaced that with was no better and no worse. What is left is the shape every screen saver uses, and the one Apple's own guidance shows: build a controller, hand over its window. `isReleasedWhenClosed` stays off. ARC owns the window through the controller, and the default would let a host that closes the sheet rather than ordering it out release it a second time. That is a real if latent bug, and unrelated to any of the above. The Options button dying is a host bug. System Settings on macOS 26 spawns duplicate legacyScreenSaver instances and loses track of them, so the sheet is handed over, attached and ordered in — against a remote view window that is no longer on screen. Traced at the moment of the click, the sheet reports isVisible == true with a live sheetParent while occlusionState never gains .visible: attached to a ghost, never drawn. Switching the screen saver away and back reproduces it every time; a freshly spawned host never shows it. Apple has it as FB19201567, with FB19204084 for the ScreenSaverView instances that pile up alongside. Neither is fixed as of 26.1b3. Nothing in configureSheet chooses the parent window, so there is nothing here to fix — the README now says so, and says to restart the host. --- README.md | 24 +++++++++++++++++++++++ Sources/ConfigureSheetController.swift | 21 +++----------------- Sources/NecoSaverView.swift | 27 ++++++++++---------------- 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 3c4b1b3..198d5a5 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,30 @@ killall legacyScreenSaver then reopen System Settings. +### Known issue: the Options button does nothing + +On macOS 26 the Options button stops responding, reliably if you switch the screen +saver away and back. Nothing appears, and nothing is logged. + +This is a bug in the host, not in the saver. System Settings spawns duplicate +`legacyScreenSaver` instances and loses track of them, so the configure sheet is +handed over, attached and ordered in — against a remote view window that is no +longer on screen. Traced from inside the bundle, the sheet reports +`isVisible == true` with a live `sheetParent`, while `occlusionState` never gains +`.visible`: attached to a ghost, and never drawn. + +Apple has it as FB19201567, along with FB19204084 for the `ScreenSaverView` +instances that pile up alongside; neither is fixed as of 26.1b3. See +[macOS 26 Tahoe Screen Saver issues](https://developer.apple.com/forums/thread/787444). + +Until it is fixed, restart the host: + +```sh +killall legacyScreenSaver +``` + +then quit System Settings and open it again. + ## Development `make preview` builds and runs `NecoSaverPreview`, a small host app that puts the diff --git a/Sources/ConfigureSheetController.swift b/Sources/ConfigureSheetController.swift index c4b91d0..bac67a1 100644 --- a/Sources/ConfigureSheetController.swift +++ b/Sources/ConfigureSheetController.swift @@ -21,7 +21,7 @@ final class ConfigureSheetController: NSObject { let window: NSWindow - private var settings = NecoSaverSettings.standard + private var settings = NecoSaverSettings.load() private let onDismiss: (_ saved: Bool) -> Void private var rows: [Row] = [] private let pictureCheckbox = NSButton(checkboxWithTitle: "Picture:", target: nil, action: nil) @@ -39,28 +39,13 @@ final class ConfigureSheetController: NSObject { super.init() window.title = "NecoSaver" // ARC owns this window through `window`; leaving the default on would let a - // host that closes the sheet release it a second time. + // host that closes the sheet rather than ordering it out release it a + // second time. window.isReleasedWhenClosed = false buildInterface() - reload() - } - - /// Re-reads the stored settings into the controls. The same sheet is handed to - /// the host every time it asks, so this is what makes the second opening show - /// what is stored rather than where the first one was left. - func reload() { - settings = Self.stored() refresh() } - /// The saved settings, with the picture seeded the same way the saver seeds it, - /// so the field shows the path the cats are actually walking on. - private static func stored() -> NecoSaverSettings { - var settings = NecoSaverSettings.load() - settings.seedPicturePath(with: Wallpaper.desktopPicturePath(for: nil) ?? "") - return settings - } - // MARK: - Interface private func buildInterface() { diff --git a/Sources/NecoSaverView.swift b/Sources/NecoSaverView.swift index c514c14..e7bd530 100644 --- a/Sources/NecoSaverView.swift +++ b/Sources/NecoSaverView.swift @@ -8,8 +8,8 @@ import ScreenSaver @objc(NecoSaverView) final class NecoSaverView: ScreenSaverView { private let engine: NecoSaverEngine - /// Built once and kept: the host releases its reference once the sheet is up, - /// and a deallocated controller takes the sheet's window and targets with it. + /// Held strongly: the host releases its reference once the sheet is up, and a + /// deallocated controller takes the sheet's targets with it. private var sheetController: ConfigureSheetController? override init?(frame: NSRect, isPreview: Bool) { @@ -62,26 +62,19 @@ final class NecoSaverView: ScreenSaverView { true } - /// Hosts read this more than once, and can replace the view between reading it - /// and presenting it. Building a controller per read would hand back a window - /// whose only owner was dropped by the very next read — and the sheet then - /// simply never appears. One controller, kept, re-reading the settings each - /// time it is handed over. override var configureSheet: NSWindow? { - let controller = sheetController ?? makeSheetController() + let controller = ConfigureSheetController { [weak self] saved in + guard let self else { return } + if saved { + engine.apply(settings()) + loadWallpaper() + } + sheetController = nil + } sheetController = controller - controller.reload() return controller.window } - private func makeSheetController() -> ConfigureSheetController { - ConfigureSheetController { [weak self] saved in - guard let self, saved else { return } - engine.apply(settings()) - loadWallpaper() - } - } - /// Reads a picture only when one is going to be drawn — decoding one costs /// several megabytes that a grey backdrop has no use for. private func loadWallpaper() {