From f9fce64581cae01a6d8afffc1ef00421179264fb Mon Sep 17 00:00:00 2001 From: winebarrel Date: Mon, 10 Aug 2026 11:31:27 +0900 Subject: [PATCH 1/2] Let the backdrop be a picture of your choosing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Desktop picture backdrop shows the wrong picture when the wallpaper is a dynamic or video one. NSWorkspace.desktopImageURL(for:) predates WallpaperKit and only reports still pictures: with the com.apple.NeptuneOneExtension provider set, the wallpaper store holds no file at all and the API answers with a placeholder, /System/Library/CoreServices/DefaultDesktop.heic, and an empty options dictionary. The saver was drawing that placeholder faithfully. No public API reports those wallpapers, so there is nothing to fix on the reading side. The Desktop picture checkbox therefore becomes a Backdrop menu — Solid grey, Desktop picture, Custom picture… — and the last of those opens a file panel and keeps what you chose. Choosing that item always opens the panel, so one menu entry also serves to swap one picture for another; cancelling with nothing chosen puts the menu back. The picture is stored as a bookmark rather than a path, so renaming or moving it does not break the backdrop. Security-scoped where the host can make one — the preview app, being unsandboxed, cannot — and a plain bookmark otherwise; the read succeeds either way, since legacyScreenSaver.appex can read anywhere under `/`. The grey slider is now labelled Grey level, which is what it does, and no longer sits one row under Backdrop meaning something else. --- Preview/PreviewApp.swift | 29 ++++++----- README.md | 29 ++++++++--- Sources/ConfigureSheetController.swift | 72 ++++++++++++++++++++++++-- Sources/NecoSaverEngine.swift | 5 +- Sources/NecoSaverSettings.swift | 48 +++++++++++++---- Sources/NecoSaverView.swift | 6 +-- Sources/Wallpaper.swift | 56 ++++++++++++++++++++ 7 files changed, 206 insertions(+), 39 deletions(-) diff --git a/Preview/PreviewApp.swift b/Preview/PreviewApp.swift index bfd1ae0..1525966 100644 --- a/Preview/PreviewApp.swift +++ b/Preview/PreviewApp.swift @@ -5,7 +5,7 @@ import AppKit /// The live window hosts the real `NecoSaverView`, so running it proves the view is /// constructible outside System Settings — the failure that otherwise only shows up /// as an empty preview after installing. Snapshots drive `NecoSaverEngine` directly -/// instead, which is what lets `--cats`, `--speed` and `--wallpaper` apply to a +/// instead, which is what lets `--cats`, `--speed` and `--backdrop` apply to a /// single render. /// /// NecoSaverPreview # live window @@ -15,7 +15,7 @@ import AppKit /// NecoSaverPreview --snapshot out.png --preview # as the System Settings thumbnail /// NecoSaverPreview --snapshot out.png --cats 5 # override the saved cat count /// NecoSaverPreview --snapshot out.png --speed 4 # override the saved speed -/// NecoSaverPreview --snapshot out.png --wallpaper off # ... and the backdrop +/// NecoSaverPreview --snapshot out.png --backdrop grey # grey|desktop|custom /// NecoSaverPreview --snapshot out.png --options # render the Options sheet instead /// /// `--size` is in points; snapshots are written at 2x, as on a Retina display. @@ -102,7 +102,7 @@ enum PreviewApp { /// Steps the engine offscreen and writes the result as a PNG. /// /// This drives `NecoSaverEngine` rather than the view, so `--cats`, `--speed` - /// and `--wallpaper` can override a setting for one render without writing to + /// and `--backdrop` can override a setting for one render without writing to /// the preferences the installed saver reads. private static func snapshot(to path: String, options: Options) { // Enough of AppKit to make NSColor usable. @@ -118,8 +118,8 @@ enum PreviewApp { if let speed = options.speed { settings.speed = speed } - if let wallpaper = options.wallpaper { - settings.wallpaperEnabled = wallpaper + if let backdrop = options.backdrop { + settings.backdrop = backdrop } let size = options.size @@ -128,9 +128,7 @@ enum PreviewApp { engine.reset(bounds: rect) // The view does this for itself; offscreen there is no window to ask which // display we are on, so the main one's picture stands in. - if settings.wallpaperEnabled { - engine.wallpaper = Wallpaper.current(for: nil) - } + engine.wallpaper = Wallpaper.matching(settings, on: nil) for _ in 0 ..< options.frames { engine.step() } @@ -213,7 +211,7 @@ private struct Options { var isPreview = false var cats: Int? var speed: Double? - var wallpaper: Bool? + var backdrop: NecoSaverSettings.Backdrop? init(_ arguments: some Sequence) throws { var explicitSize = false @@ -224,7 +222,7 @@ private struct Options { showOptions = true case "--preview": isPreview = true - case "--snapshot", "--frames", "--size", "--cats", "--speed", "--wallpaper": + case "--snapshot", "--frames", "--size", "--cats", "--speed", "--backdrop": let raw = try Self.value(after: argument, from: &iterator) try set(argument, to: raw, explicitSize: &explicitSize) default: @@ -258,11 +256,14 @@ private struct Options { case "--size": size = try Self.parseSize(raw) explicitSize = true - case "--wallpaper": - guard let value = ["on": true, "off": false][raw] else { - throw OptionError("\(flag) expects on or off, got \(raw)") + case "--backdrop": + let choices: [String: NecoSaverSettings.Backdrop] = [ + "grey": .solidGrey, "desktop": .desktopPicture, "custom": .customPicture, + ] + guard let value = choices[raw] else { + throw OptionError("\(flag) expects grey, desktop or custom, got \(raw)") } - wallpaper = value + backdrop = value default: guard let value = Double(raw), value > 0 else { throw OptionError("\(flag) expects a positive number, got \(raw)") diff --git a/README.md b/README.md index 109ee07..8445033 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,33 @@ random point instead of the pointer. | Cats | 1 | 1–8 | Cats on screen. Each one wanders on its own. | | Size | 64 pt | 32–192 pt | Sprite scale. The oneko bitmaps are 32×32, so 64 pt is the 2× cat Neco itself draws. Walking speed and the size of the mess scale with it. | | Speed | 1.00× | 0.2–3.0 | | -| Background | 0% | 0–100% | Grey level behind the cat, when no desktop picture is drawn. | -| Desktop picture | on | | Use the display's own wallpaper as the backdrop, laid out the way the desktop lays it out — Fill Screen, Fit to Screen, Stretch or Centre, fill colour and all. Falls back to the grey when there is no still picture to read. | +| Backdrop | Desktop picture | | What the cats walk on: a solid grey, the display's own wallpaper, or a picture you pick. | +| Grey level | 0% | 0–100% | Used by the `Solid grey` backdrop, and whenever a picture cannot be read. | | Paw prints | on | | Dropped while running; fade out after 12 s. | | Scratch marks | on | | Dropped while the cat works its claws; fade out after 20 s. | +`Desktop picture` follows the layout the desktop itself uses — Fill Screen, Fit to +Screen, Stretch or Centre, fill colour and all. `Custom picture…` opens a file +panel and remembers what you chose, laid out as Fill Screen. + Paw prints and scratch marks are inked light or dark against whatever the backdrop -turns out to be, so they stay visible over a black screen and a bright wallpaper +turns out to be, so they stay visible over a black screen and a bright picture alike. -Note that this is the wallpaper *picture*, not the live desktop: a screen saver is -a window covering the screen at `CGShieldingWindowLevel()`, so nothing below it can -show through. Windows, icons and the menu bar are not part of it. +### Dynamic and video wallpapers + +`Desktop picture` reads `NSWorkspace.desktopImageURL(for:)`, which predates +WallpaperKit and only knows about still pictures. With a dynamic or video wallpaper +set — anything from the `com.apple.NeptuneOneExtension` provider — it answers with +a placeholder (`/System/Library/CoreServices/DefaultDesktop.heic`) and no layout +options, so the saver ends up drawing a picture nobody chose. There is no public +API that reports those wallpapers. Use `Custom picture…` instead. + +### Not the live desktop + +This is the wallpaper *picture*. A screen saver is a window covering the screen at +`CGShieldingWindowLevel()`, so nothing below it can show through: windows, icons +and the menu bar are not part of it, and no amount of transparency changes that. Settings are stored per user through `ScreenSaverDefaults` under `jp.winebarrel.NecoSaver`. @@ -62,7 +77,7 @@ NecoSaverPreview --snapshot out.png --frames 900 --size 1920x1080 NecoSaverPreview --snapshot out.png --preview # as the System Settings thumbnail NecoSaverPreview --snapshot out.png --cats 5 # override the saved cat count NecoSaverPreview --snapshot out.png --speed 4 # override the saved speed -NecoSaverPreview --snapshot out.png --wallpaper off # ... and the backdrop +NecoSaverPreview --snapshot out.png --backdrop grey # grey|desktop|custom NecoSaverPreview --snapshot out.png --options # render the Options sheet instead ``` diff --git a/Sources/ConfigureSheetController.swift b/Sources/ConfigureSheetController.swift index ebea5e0..5ac0fca 100644 --- a/Sources/ConfigureSheetController.swift +++ b/Sources/ConfigureSheetController.swift @@ -1,4 +1,5 @@ import AppKit +import UniformTypeIdentifiers /// The "Options…" sheet, built in code. /// @@ -23,6 +24,8 @@ final class ConfigureSheetController: NSObject { private var settings = NecoSaverSettings.load() private let onDismiss: (_ saved: Bool) -> Void private var rows: [Row] = [] + private let backdropPopUp = NSPopUpButton() + private let backdropLabel = NSTextField(labelWithString: "") init(onDismiss: @escaping (_ saved: Bool) -> Void) { self.onDismiss = onDismiss @@ -88,11 +91,12 @@ final class ConfigureSheetController: NSObject { makeRow(title: "Speed:", control: makeSlider(range: settings.speedRange), describe: multiplier, read: { $0.speed }, write: { $0.speed = $1 }), - makeRow(title: "Background:", control: makeSlider(range: settings.backgroundRange), + makeBackdropRow(), + // Named for what it controls rather than "Background", which would sit + // one row under "Backdrop" and mean something else. + makeRow(title: "Grey level:", control: makeSlider(range: settings.backgroundRange), describe: percent, read: { $0.background }, write: { $0.background = $1 }), - makeCheckboxRow(title: "Desktop picture", read: { $0.wallpaperEnabled }, - write: { $0.wallpaperEnabled = $1 }), makeCheckboxRow(title: "Paw prints", read: { $0.pawsEnabled }, write: { $0.pawsEnabled = $1 }), makeCheckboxRow(title: "Scratch marks", read: { $0.scratchEnabled }, @@ -100,6 +104,23 @@ final class ConfigureSheetController: NSObject { ] } + /// The backdrop chooser. Outside the `Row` machinery, which speaks in doubles: + /// this one carries a menu selection and, alongside it, the name of the file + /// the user picked. + private func makeBackdropRow() -> [NSView] { + backdropPopUp.addItems(withTitles: NecoSaverSettings.Backdrop.allCases.map(\.title)) + backdropPopUp.target = self + backdropPopUp.action = #selector(backdropChanged) + backdropPopUp.translatesAutoresizingMaskIntoConstraints = false + backdropPopUp.widthAnchor.constraint(equalToConstant: 220).isActive = true + + backdropLabel.lineBreakMode = .byTruncatingMiddle + backdropLabel.textColor = .secondaryLabelColor + backdropLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + + return [NSTextField(labelWithString: "Backdrop:"), backdropPopUp, backdropLabel] + } + private func makeButtons() -> NSStackView { let restore = NSButton(title: "Restore Defaults", target: self, action: #selector(restoreDefaults)) @@ -189,6 +210,17 @@ final class ConfigureSheetController: NSObject { row.control.doubleValue = value row.valueLabel?.stringValue = row.describe(value) } + backdropPopUp.selectItem(at: settings.backdrop.rawValue) + backdropLabel.stringValue = pictureName ?? "" + } + + /// The name of the chosen picture, for the label beside the menu. Nil when + /// there is none to show — a different backdrop, or a file that has since gone. + private var pictureName: String? { + guard settings.backdrop == .customPicture, + let bookmark = settings.customPicture, + let url = Wallpaper.url(from: bookmark) else { return nil } + return url.lastPathComponent } // MARK: - Actions @@ -199,6 +231,40 @@ final class ConfigureSheetController: NSObject { refresh() } + /// Choosing "Custom picture…" opens the panel every time, so the same item can + /// be used to swap one picture for another. + @objc private func backdropChanged() { + let previous = settings.backdrop + settings.backdrop = NecoSaverSettings.Backdrop(rawValue: backdropPopUp.indexOfSelectedItem) ?? previous + + if settings.backdrop == .customPicture { + choosePicture(revertingTo: previous) + } else { + refresh() + } + } + + private func choosePicture(revertingTo previous: NecoSaverSettings.Backdrop) { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.image] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.prompt = "Choose" + panel.message = "Pick a picture for the cats to walk on." + + panel.beginSheetModal(for: window) { [weak self] response in + guard let self else { return } + if response == .OK, let url = panel.url, let bookmark = Wallpaper.bookmark(for: url) { + settings.customPicture = bookmark + } else if settings.customPicture == nil { + // Cancelled with nothing chosen before: there is no custom picture + // to fall back on, so put the menu back where it was. + settings.backdrop = previous + } + refresh() + } + } + @objc private func restoreDefaults() { settings = .standard refresh() diff --git a/Sources/NecoSaverEngine.swift b/Sources/NecoSaverEngine.swift index 72b9f08..c826246 100644 --- a/Sources/NecoSaverEngine.swift +++ b/Sources/NecoSaverEngine.swift @@ -112,9 +112,10 @@ final class NecoSaverEngine { Tuning(scale: settings.size, speedFactor: settings.speed) } - /// The wallpaper, if one was supplied and wanted. + /// The picture to sit the cats on, if one was supplied. Nil leaves the grey: + /// either it was asked for, or nothing could be read. private var backdrop: Wallpaper? { - settings.wallpaperEnabled ? wallpaper : nil + settings.backdrop == .solidGrey ? nil : wallpaper } private func configureLitter() { diff --git a/Sources/NecoSaverSettings.swift b/Sources/NecoSaverSettings.swift index d2c312f..77c9f1b 100644 --- a/Sources/NecoSaverSettings.swift +++ b/Sources/NecoSaverSettings.swift @@ -11,18 +11,38 @@ struct NecoSaverSettings: Equatable { /// Must match `CFBundleIdentifier` in `Sources/Info.plist`. static let moduleName = "jp.winebarrel.NecoSaver" + /// What the cats walk on. + enum Backdrop: Int, CaseIterable { + case solidGrey = 0 + /// The display's own wallpaper, as reported by `NSWorkspace`. That API + /// predates WallpaperKit and only knows about still pictures: with a + /// dynamic or video wallpaper set it answers with a placeholder, which is + /// why `customPicture` exists. + case desktopPicture = 1 + case customPicture = 2 + + var title: String { + switch self { + case .solidGrey: "Solid grey" + case .desktopPicture: "Desktop picture" + case .customPicture: "Custom picture…" + } + } + } + var catCount: Int /// Sprite scale. The oneko bitmaps are 32x32, so 2.0 draws a 64pt cat — the /// size Neco itself uses. var size: Double var speed: Double /// Background grey level, 0 = black ... 1 = white. The cat is black ink on a - /// white body, so it reads at either end; the litter marks flip to suit. - /// Ignored while `wallpaperEnabled` finds a picture to draw. + /// white body, so it reads at either end; the litter marks flip to suit. Used + /// whenever no picture is drawn, either by choice or because none could be read. var background: Double - /// Draw the display's desktop picture instead of the flat grey. Falls back to - /// the grey when there is no still picture to read — a video wallpaper, say. - var wallpaperEnabled: Bool + var backdrop: Backdrop + /// Bookmark to the picture chosen through the Options sheet. A bookmark rather + /// than a path so that renaming or moving the file does not break the backdrop. + var customPicture: Data? var pawsEnabled: Bool var scratchEnabled: Bool @@ -31,7 +51,8 @@ struct NecoSaverSettings: Equatable { size: 2.0, speed: 1.0, background: 0.0, - wallpaperEnabled: true, + backdrop: .desktopPicture, + customPicture: nil, pawsEnabled: true, scratchEnabled: true ) @@ -46,7 +67,8 @@ struct NecoSaverSettings: Equatable { static let size = "size" static let speed = "speed" static let background = "background" - static let wallpaperEnabled = "wallpaperEnabled" + static let backdrop = "backdrop" + static let customPicture = "customPicture" static let pawsEnabled = "pawsEnabled" static let scratchEnabled = "scratchEnabled" } @@ -58,7 +80,7 @@ struct NecoSaverSettings: Equatable { Key.size: standard.size, Key.speed: standard.speed, Key.background: standard.background, - Key.wallpaperEnabled: standard.wallpaperEnabled, + Key.backdrop: standard.backdrop.rawValue, Key.pawsEnabled: standard.pawsEnabled, Key.scratchEnabled: standard.scratchEnabled, ]) @@ -74,7 +96,8 @@ struct NecoSaverSettings: Equatable { size: sizeRange.clamping(store.double(forKey: Key.size)), speed: speedRange.clamping(store.double(forKey: Key.speed)), background: backgroundRange.clamping(store.double(forKey: Key.background)), - wallpaperEnabled: store.bool(forKey: Key.wallpaperEnabled), + backdrop: Backdrop(rawValue: store.integer(forKey: Key.backdrop)) ?? standard.backdrop, + customPicture: store.data(forKey: Key.customPicture), pawsEnabled: store.bool(forKey: Key.pawsEnabled), scratchEnabled: store.bool(forKey: Key.scratchEnabled) ) @@ -95,7 +118,12 @@ struct NecoSaverSettings: Equatable { store.set(size, forKey: Key.size) store.set(speed, forKey: Key.speed) store.set(background, forKey: Key.background) - store.set(wallpaperEnabled, forKey: Key.wallpaperEnabled) + store.set(backdrop.rawValue, forKey: Key.backdrop) + if let customPicture { + store.set(customPicture, forKey: Key.customPicture) + } else { + store.removeObject(forKey: Key.customPicture) + } store.set(pawsEnabled, forKey: Key.pawsEnabled) store.set(scratchEnabled, forKey: Key.scratchEnabled) store.synchronize() diff --git a/Sources/NecoSaverView.swift b/Sources/NecoSaverView.swift index 0e637e2..ae21375 100644 --- a/Sources/NecoSaverView.swift +++ b/Sources/NecoSaverView.swift @@ -72,10 +72,10 @@ final class NecoSaverView: ScreenSaverView { return controller.window } - /// Reads the desktop picture only when it is going to be drawn — decoding one - /// costs several megabytes that a grey backdrop has no use for. + /// 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() { - engine.wallpaper = engine.settings.wallpaperEnabled ? Wallpaper.current(for: window?.screen) : nil + engine.wallpaper = Wallpaper.matching(engine.settings, on: window?.screen) } private static func settings(isPreview: Bool) -> NecoSaverSettings { diff --git a/Sources/Wallpaper.swift b/Sources/Wallpaper.swift index b7b06f1..49c435b 100644 --- a/Sources/Wallpaper.swift +++ b/Sources/Wallpaper.swift @@ -15,8 +15,23 @@ struct Wallpaper { private let allowsClipping: Bool private let fillColor: NSColor + /// The backdrop the settings ask for, or nil to leave the grey showing. + static func matching(_ settings: NecoSaverSettings, on screen: NSScreen?) -> Wallpaper? { + switch settings.backdrop { + case .solidGrey: nil + case .desktopPicture: current(for: screen) + case .customPicture: custom(settings.customPicture) + } + } + /// Reads the picture currently set on `screen`, or on the main display when /// the caller has no window to ask. + /// + /// `NSWorkspace`'s wallpaper API predates WallpaperKit and only reports still + /// pictures. With a dynamic or video wallpaper set it hands back a placeholder + /// (`/System/Library/CoreServices/DefaultDesktop.heic`) and no options at all, + /// which is a picture the user never chose — that is what `customPicture` is + /// for. static func current(for screen: NSScreen?) -> Wallpaper? { guard let screen = screen ?? NSScreen.main, let url = NSWorkspace.shared.desktopImageURL(for: screen), @@ -32,6 +47,47 @@ struct Wallpaper { ) } + /// Reads the picture chosen through the Options sheet. + /// + /// There are no system layout options for a picture the desktop knows nothing + /// about, so it is laid out as Fill Screen — the setting most desktops use. + static func custom(_ bookmark: Data?) -> Wallpaper? { + guard let bookmark, let url = url(from: bookmark) else { return nil } + + // No-op for a plain bookmark, which is what a non-sandboxed host produces; + // the read succeeds either way, since legacyScreenSaver.appex can read + // anywhere. Balanced regardless, as the documentation insists. + let scoped = url.startAccessingSecurityScopedResource() + defer { + if scoped { + url.stopAccessingSecurityScopedResource() + } + } + + guard let image = NSImage(contentsOf: url) else { return nil } + return Wallpaper(image: image, scaling: .scaleProportionallyUpOrDown, + allowsClipping: true, fillColor: .black) + } + + /// A bookmark for a picture the user just chose, for `NecoSaverSettings` to + /// keep. Security-scoped where the host is sandboxed enough to make one — the + /// preview app is not — and a plain bookmark otherwise. + static func bookmark(for url: URL) -> Data? { + if let scoped = try? url.bookmarkData(options: .withSecurityScope) { + return scoped + } + return try? url.bookmarkData() + } + + /// Resolves a bookmark from either flavour, ignoring staleness: a stale + /// bookmark still names the file, and this side only ever reads. + static func url(from bookmark: Data) -> URL? { + var stale = false + let scoped = try? URL(resolvingBookmarkData: bookmark, options: .withSecurityScope, + relativeTo: nil, bookmarkDataIsStale: &stale) + return scoped ?? (try? URL(resolvingBookmarkData: bookmark, relativeTo: nil, bookmarkDataIsStale: &stale)) + } + func draw(in rect: CGRect, context: CGContext) { // Painted first either way: in Fit and Center the picture leaves a margin, // and this is the colour the desktop shows there. From d4d8ac9f651225e61e1fc461eba7c0f90c221781 Mon Sep 17 00:00:00 2001 From: winebarrel Date: Mon, 10 Aug 2026 11:46:30 +0900 Subject: [PATCH 2/2] Make the backdrop a picture path, seeded with the wallpaper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-way Backdrop menu becomes a checkbox and a path field. There is one source for the backdrop now — a file — and the desktop's wallpaper is only where its value starts. The path is filled in once, on first run, from NSWorkspace.desktopImageURL(for:), and is an ordinary saved setting from then on: changing the wallpaper later leaves the saver alone. That removes the awkward middle case the menu had, where "Desktop picture" silently followed an API that cannot see dynamic or video wallpapers and answered with a placeholder. The placeholder can still turn up as the seeded value, but now it is sitting in a field, visible and correctable, rather than deciding the backdrop from behind the scenes. Storing a path rather than a bookmark drops the security-scoped bookmark machinery. A typed path is not a user-selected file in the sandbox's sense, but legacyScreenSaver.appex can read anywhere under `/`, so the read succeeds either way and there is nothing left for a bookmark to buy. The desktop's layout options still apply when the path happens to name the very picture the desktop is showing, which is the usual case; anything else is laid out as Fill Screen. --- Preview/PreviewApp.swift | 34 ++++----- README.md | 23 +++--- Sources/ConfigureSheetController.swift | 99 +++++++++++++------------- Sources/NecoSaverEngine.swift | 2 +- Sources/NecoSaverSettings.swift | 63 +++++++--------- Sources/NecoSaverView.swift | 24 ++++--- Sources/Wallpaper.swift | 95 ++++++++++-------------- 7 files changed, 159 insertions(+), 181 deletions(-) diff --git a/Preview/PreviewApp.swift b/Preview/PreviewApp.swift index 1525966..69026b1 100644 --- a/Preview/PreviewApp.swift +++ b/Preview/PreviewApp.swift @@ -5,7 +5,7 @@ import AppKit /// The live window hosts the real `NecoSaverView`, so running it proves the view is /// constructible outside System Settings — the failure that otherwise only shows up /// as an empty preview after installing. Snapshots drive `NecoSaverEngine` directly -/// instead, which is what lets `--cats`, `--speed` and `--backdrop` apply to a +/// instead, which is what lets `--cats`, `--speed` and `--picture` apply to a /// single render. /// /// NecoSaverPreview # live window @@ -15,7 +15,8 @@ import AppKit /// NecoSaverPreview --snapshot out.png --preview # as the System Settings thumbnail /// NecoSaverPreview --snapshot out.png --cats 5 # override the saved cat count /// NecoSaverPreview --snapshot out.png --speed 4 # override the saved speed -/// NecoSaverPreview --snapshot out.png --backdrop grey # grey|desktop|custom +/// NecoSaverPreview --snapshot out.png --picture pic.png # override the backdrop +/// NecoSaverPreview --snapshot out.png --grey # ... or leave the grey showing /// NecoSaverPreview --snapshot out.png --options # render the Options sheet instead /// /// `--size` is in points; snapshots are written at 2x, as on a Retina display. @@ -102,7 +103,7 @@ enum PreviewApp { /// Steps the engine offscreen and writes the result as a PNG. /// /// This drives `NecoSaverEngine` rather than the view, so `--cats`, `--speed` - /// and `--backdrop` can override a setting for one render without writing to + /// and `--picture` can override a setting for one render without writing to /// the preferences the installed saver reads. private static func snapshot(to path: String, options: Options) { // Enough of AppKit to make NSColor usable. @@ -118,8 +119,12 @@ enum PreviewApp { if let speed = options.speed { settings.speed = speed } - if let backdrop = options.backdrop { - settings.backdrop = backdrop + settings.seedPicturePath(with: Wallpaper.desktopPicturePath(for: nil) ?? "") + if let picture = options.picture { + settings.picturePath = picture + } + if options.grey { + settings.pictureEnabled = false } let size = options.size @@ -127,7 +132,7 @@ enum PreviewApp { let engine = NecoSaverEngine(settings: settings) engine.reset(bounds: rect) // The view does this for itself; offscreen there is no window to ask which - // display we are on, so the main one's picture stands in. + // display we are on, so the main one answers for it. engine.wallpaper = Wallpaper.matching(settings, on: nil) for _ in 0 ..< options.frames { engine.step() @@ -211,7 +216,8 @@ private struct Options { var isPreview = false var cats: Int? var speed: Double? - var backdrop: NecoSaverSettings.Backdrop? + var picture: String? + var grey = false init(_ arguments: some Sequence) throws { var explicitSize = false @@ -222,7 +228,9 @@ private struct Options { showOptions = true case "--preview": isPreview = true - case "--snapshot", "--frames", "--size", "--cats", "--speed", "--backdrop": + case "--grey": + grey = true + case "--snapshot", "--frames", "--size", "--cats", "--speed", "--picture": let raw = try Self.value(after: argument, from: &iterator) try set(argument, to: raw, explicitSize: &explicitSize) default: @@ -256,14 +264,8 @@ private struct Options { case "--size": size = try Self.parseSize(raw) explicitSize = true - case "--backdrop": - let choices: [String: NecoSaverSettings.Backdrop] = [ - "grey": .solidGrey, "desktop": .desktopPicture, "custom": .customPicture, - ] - guard let value = choices[raw] else { - throw OptionError("\(flag) expects grey, desktop or custom, got \(raw)") - } - backdrop = value + case "--picture": + picture = raw default: guard let value = Double(raw), value > 0 else { throw OptionError("\(flag) expects a positive number, got \(raw)") diff --git a/README.md b/README.md index 8445033..3c4b1b3 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,16 @@ random point instead of the pointer. | Cats | 1 | 1–8 | Cats on screen. Each one wanders on its own. | | Size | 64 pt | 32–192 pt | Sprite scale. The oneko bitmaps are 32×32, so 64 pt is the 2× cat Neco itself draws. Walking speed and the size of the mess scale with it. | | Speed | 1.00× | 0.2–3.0 | | -| Backdrop | Desktop picture | | What the cats walk on: a solid grey, the display's own wallpaper, or a picture you pick. | -| Grey level | 0% | 0–100% | Used by the `Solid grey` backdrop, and whenever a picture cannot be read. | +| Picture | on, your wallpaper | | The picture the cats walk on, by path. Type one, or press `Choose…`. Turn it off, or empty the field, for the grey. | +| Grey level | 0% | 0–100% | The colour behind the cats when no picture is drawn, either by choice or because the path could not be read. | | Paw prints | on | | Dropped while running; fade out after 12 s. | | Scratch marks | on | | Dropped while the cat works its claws; fade out after 20 s. | -`Desktop picture` follows the layout the desktop itself uses — Fill Screen, Fit to -Screen, Stretch or Centre, fill colour and all. `Custom picture…` opens a file -panel and remembers what you chose, laid out as Fill Screen. +The picture starts out as whatever the desktop's wallpaper is on first run, and is +an ordinary saved setting from then on: changing your wallpaper later leaves the +saver alone. When the two happen to be the same file the desktop's own layout +applies — Fill Screen, Fit to Screen, Stretch or Centre, fill colour and all — and +any other picture is laid out as Fill Screen. Paw prints and scratch marks are inked light or dark against whatever the backdrop turns out to be, so they stay visible over a black screen and a bright picture @@ -32,12 +34,12 @@ alike. ### Dynamic and video wallpapers -`Desktop picture` reads `NSWorkspace.desktopImageURL(for:)`, which predates +The path is seeded from `NSWorkspace.desktopImageURL(for:)`, which predates WallpaperKit and only knows about still pictures. With a dynamic or video wallpaper set — anything from the `com.apple.NeptuneOneExtension` provider — it answers with -a placeholder (`/System/Library/CoreServices/DefaultDesktop.heic`) and no layout -options, so the saver ends up drawing a picture nobody chose. There is no public -API that reports those wallpapers. Use `Custom picture…` instead. +a placeholder, `/System/Library/CoreServices/DefaultDesktop.heic`, and no layout +options. No public API reports those wallpapers, so if the field starts out naming +that file, point it at a picture you actually want. ### Not the live desktop @@ -77,7 +79,8 @@ NecoSaverPreview --snapshot out.png --frames 900 --size 1920x1080 NecoSaverPreview --snapshot out.png --preview # as the System Settings thumbnail NecoSaverPreview --snapshot out.png --cats 5 # override the saved cat count NecoSaverPreview --snapshot out.png --speed 4 # override the saved speed -NecoSaverPreview --snapshot out.png --backdrop grey # grey|desktop|custom +NecoSaverPreview --snapshot out.png --picture pic.png # override the backdrop +NecoSaverPreview --snapshot out.png --grey # ... or leave the grey showing NecoSaverPreview --snapshot out.png --options # render the Options sheet instead ``` diff --git a/Sources/ConfigureSheetController.swift b/Sources/ConfigureSheetController.swift index 5ac0fca..4790dad 100644 --- a/Sources/ConfigureSheetController.swift +++ b/Sources/ConfigureSheetController.swift @@ -24,8 +24,9 @@ final class ConfigureSheetController: NSObject { private var settings = NecoSaverSettings.load() private let onDismiss: (_ saved: Bool) -> Void private var rows: [Row] = [] - private let backdropPopUp = NSPopUpButton() - private let backdropLabel = NSTextField(labelWithString: "") + private let pictureCheckbox = NSButton(checkboxWithTitle: "Picture:", target: nil, action: nil) + private let pictureField = NSTextField() + private let pictureButton = NSButton(title: "Choose…", target: nil, action: nil) init(onDismiss: @escaping (_ saved: Bool) -> Void) { self.onDismiss = onDismiss @@ -69,6 +70,9 @@ final class ConfigureSheetController: NSObject { let grid = NSGridView(views: makeRows()) grid.translatesAutoresizingMaskIntoConstraints = false grid.column(at: 0).xPlacement = .trailing + // The picture row's Choose… button shares this column with the narrow value + // labels; right-aligning it keeps the numbers flush with the button's edge. + grid.column(at: 2).xPlacement = .trailing grid.rowSpacing = 10 grid.columnSpacing = 10 return grid @@ -91,9 +95,9 @@ final class ConfigureSheetController: NSObject { makeRow(title: "Speed:", control: makeSlider(range: settings.speedRange), describe: multiplier, read: { $0.speed }, write: { $0.speed = $1 }), - makeBackdropRow(), - // Named for what it controls rather than "Background", which would sit - // one row under "Backdrop" and mean something else. + makePictureRow(), + // Named for what it does. It is the whole backdrop only when no + // picture is drawn, so "Background" would overstate it. makeRow(title: "Grey level:", control: makeSlider(range: settings.backgroundRange), describe: percent, read: { $0.background }, write: { $0.background = $1 }), @@ -104,21 +108,23 @@ final class ConfigureSheetController: NSObject { ] } - /// The backdrop chooser. Outside the `Row` machinery, which speaks in doubles: - /// this one carries a menu selection and, alongside it, the name of the file - /// the user picked. - private func makeBackdropRow() -> [NSView] { - backdropPopUp.addItems(withTitles: NecoSaverSettings.Backdrop.allCases.map(\.title)) - backdropPopUp.target = self - backdropPopUp.action = #selector(backdropChanged) - backdropPopUp.translatesAutoresizingMaskIntoConstraints = false - backdropPopUp.widthAnchor.constraint(equalToConstant: 220).isActive = true - - backdropLabel.lineBreakMode = .byTruncatingMiddle - backdropLabel.textColor = .secondaryLabelColor - backdropLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize) - - return [NSTextField(labelWithString: "Backdrop:"), backdropPopUp, backdropLabel] + /// The picture chooser: the checkbox stands in for the row's label, the field + /// holds the path, and the button fills it in from a file panel. Outside the + /// `Row` machinery, which speaks in doubles. + private func makePictureRow() -> [NSView] { + pictureCheckbox.target = self + pictureCheckbox.action = #selector(pictureEnabledChanged) + + pictureField.delegate = self + pictureField.placeholderString = "Path to a picture" + pictureField.lineBreakMode = .byTruncatingHead + pictureField.translatesAutoresizingMaskIntoConstraints = false + pictureField.widthAnchor.constraint(equalToConstant: 220).isActive = true + + pictureButton.target = self + pictureButton.action = #selector(choosePicture) + + return [pictureCheckbox, pictureField, pictureButton] } private func makeButtons() -> NSStackView { @@ -210,17 +216,10 @@ final class ConfigureSheetController: NSObject { row.control.doubleValue = value row.valueLabel?.stringValue = row.describe(value) } - backdropPopUp.selectItem(at: settings.backdrop.rawValue) - backdropLabel.stringValue = pictureName ?? "" - } - - /// The name of the chosen picture, for the label beside the menu. Nil when - /// there is none to show — a different backdrop, or a file that has since gone. - private var pictureName: String? { - guard settings.backdrop == .customPicture, - let bookmark = settings.customPicture, - let url = Wallpaper.url(from: bookmark) else { return nil } - return url.lastPathComponent + pictureCheckbox.state = settings.pictureEnabled ? .on : .off + pictureField.stringValue = settings.picturePath + pictureField.isEnabled = settings.pictureEnabled + pictureButton.isEnabled = settings.pictureEnabled } // MARK: - Actions @@ -231,36 +230,25 @@ final class ConfigureSheetController: NSObject { refresh() } - /// Choosing "Custom picture…" opens the panel every time, so the same item can - /// be used to swap one picture for another. - @objc private func backdropChanged() { - let previous = settings.backdrop - settings.backdrop = NecoSaverSettings.Backdrop(rawValue: backdropPopUp.indexOfSelectedItem) ?? previous - - if settings.backdrop == .customPicture { - choosePicture(revertingTo: previous) - } else { - refresh() - } + @objc private func pictureEnabledChanged() { + settings.pictureEnabled = pictureCheckbox.state == .on + refresh() } - private func choosePicture(revertingTo previous: NecoSaverSettings.Backdrop) { + @objc private func choosePicture() { let panel = NSOpenPanel() panel.allowedContentTypes = [.image] panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.prompt = "Choose" panel.message = "Pick a picture for the cats to walk on." + if !settings.picturePath.isEmpty { + panel.directoryURL = URL(fileURLWithPath: settings.picturePath).deletingLastPathComponent() + } panel.beginSheetModal(for: window) { [weak self] response in - guard let self else { return } - if response == .OK, let url = panel.url, let bookmark = Wallpaper.bookmark(for: url) { - settings.customPicture = bookmark - } else if settings.customPicture == nil { - // Cancelled with nothing chosen before: there is no custom picture - // to fall back on, so put the menu back where it was. - settings.backdrop = previous - } + guard let self, response == .OK, let url = panel.url else { return } + settings.picturePath = url.path refresh() } } @@ -292,3 +280,12 @@ final class ConfigureSheetController: NSObject { onDismiss(saved) } } + +/// Keeps the path in step with the field as it is typed, so OK saves what is on +/// screen whether or not the field was committed with Return first. +extension ConfigureSheetController: NSTextFieldDelegate { + func controlTextDidChange(_ notification: Notification) { + guard notification.object as AnyObject? === pictureField else { return } + settings.picturePath = pictureField.stringValue + } +} diff --git a/Sources/NecoSaverEngine.swift b/Sources/NecoSaverEngine.swift index c826246..1995df4 100644 --- a/Sources/NecoSaverEngine.swift +++ b/Sources/NecoSaverEngine.swift @@ -115,7 +115,7 @@ final class NecoSaverEngine { /// The picture to sit the cats on, if one was supplied. Nil leaves the grey: /// either it was asked for, or nothing could be read. private var backdrop: Wallpaper? { - settings.backdrop == .solidGrey ? nil : wallpaper + settings.pictureEnabled ? wallpaper : nil } private func configureLitter() { diff --git a/Sources/NecoSaverSettings.swift b/Sources/NecoSaverSettings.swift index 77c9f1b..c46587c 100644 --- a/Sources/NecoSaverSettings.swift +++ b/Sources/NecoSaverSettings.swift @@ -11,25 +11,6 @@ struct NecoSaverSettings: Equatable { /// Must match `CFBundleIdentifier` in `Sources/Info.plist`. static let moduleName = "jp.winebarrel.NecoSaver" - /// What the cats walk on. - enum Backdrop: Int, CaseIterable { - case solidGrey = 0 - /// The display's own wallpaper, as reported by `NSWorkspace`. That API - /// predates WallpaperKit and only knows about still pictures: with a - /// dynamic or video wallpaper set it answers with a placeholder, which is - /// why `customPicture` exists. - case desktopPicture = 1 - case customPicture = 2 - - var title: String { - switch self { - case .solidGrey: "Solid grey" - case .desktopPicture: "Desktop picture" - case .customPicture: "Custom picture…" - } - } - } - var catCount: Int /// Sprite scale. The oneko bitmaps are 32x32, so 2.0 draws a 64pt cat — the /// size Neco itself uses. @@ -39,10 +20,12 @@ struct NecoSaverSettings: Equatable { /// white body, so it reads at either end; the litter marks flip to suit. Used /// whenever no picture is drawn, either by choice or because none could be read. var background: Double - var backdrop: Backdrop - /// Bookmark to the picture chosen through the Options sheet. A bookmark rather - /// than a path so that renaming or moving the file does not break the backdrop. - var customPicture: Data? + /// Draw `picturePath` behind the cats rather than the flat grey. + var pictureEnabled: Bool + /// The picture to draw. Seeded once with the desktop's wallpaper (see + /// `seedPicturePath`), an ordinary saved value from then on; empty, or naming + /// a file that cannot be read, leaves the grey showing. + var picturePath: String var pawsEnabled: Bool var scratchEnabled: Bool @@ -51,8 +34,8 @@ struct NecoSaverSettings: Equatable { size: 2.0, speed: 1.0, background: 0.0, - backdrop: .desktopPicture, - customPicture: nil, + pictureEnabled: true, + picturePath: "", pawsEnabled: true, scratchEnabled: true ) @@ -67,8 +50,8 @@ struct NecoSaverSettings: Equatable { static let size = "size" static let speed = "speed" static let background = "background" - static let backdrop = "backdrop" - static let customPicture = "customPicture" + static let pictureEnabled = "pictureEnabled" + static let picturePath = "picturePath" static let pawsEnabled = "pawsEnabled" static let scratchEnabled = "scratchEnabled" } @@ -80,7 +63,8 @@ struct NecoSaverSettings: Equatable { Key.size: standard.size, Key.speed: standard.speed, Key.background: standard.background, - Key.backdrop: standard.backdrop.rawValue, + Key.pictureEnabled: standard.pictureEnabled, + Key.picturePath: standard.picturePath, Key.pawsEnabled: standard.pawsEnabled, Key.scratchEnabled: standard.scratchEnabled, ]) @@ -96,8 +80,8 @@ struct NecoSaverSettings: Equatable { size: sizeRange.clamping(store.double(forKey: Key.size)), speed: speedRange.clamping(store.double(forKey: Key.speed)), background: backgroundRange.clamping(store.double(forKey: Key.background)), - backdrop: Backdrop(rawValue: store.integer(forKey: Key.backdrop)) ?? standard.backdrop, - customPicture: store.data(forKey: Key.customPicture), + pictureEnabled: store.bool(forKey: Key.pictureEnabled), + picturePath: store.string(forKey: Key.picturePath) ?? standard.picturePath, pawsEnabled: store.bool(forKey: Key.pawsEnabled), scratchEnabled: store.bool(forKey: Key.scratchEnabled) ) @@ -112,18 +96,25 @@ struct NecoSaverSettings: Equatable { return adjusted } + /// Fills in the picture on first run, when nothing has been chosen yet: the + /// desktop's own wallpaper stands in, and is written down. From then on it is + /// an ordinary saved value — changing the wallpaper later leaves the backdrop + /// alone, and clearing the field leaves the grey. + mutating func seedPicturePath(with path: String) { + guard picturePath.isEmpty, !path.isEmpty, let store = Self.store else { return } + picturePath = path + store.set(path, forKey: Key.picturePath) + store.synchronize() + } + func save() { guard let store = Self.store else { return } store.set(catCount, forKey: Key.catCount) store.set(size, forKey: Key.size) store.set(speed, forKey: Key.speed) store.set(background, forKey: Key.background) - store.set(backdrop.rawValue, forKey: Key.backdrop) - if let customPicture { - store.set(customPicture, forKey: Key.customPicture) - } else { - store.removeObject(forKey: Key.customPicture) - } + store.set(pictureEnabled, forKey: Key.pictureEnabled) + store.set(picturePath, forKey: Key.picturePath) store.set(pawsEnabled, forKey: Key.pawsEnabled) store.set(scratchEnabled, forKey: Key.scratchEnabled) store.synchronize() diff --git a/Sources/NecoSaverView.swift b/Sources/NecoSaverView.swift index ae21375..e7bd530 100644 --- a/Sources/NecoSaverView.swift +++ b/Sources/NecoSaverView.swift @@ -13,7 +13,10 @@ final class NecoSaverView: ScreenSaverView { private var sheetController: ConfigureSheetController? override init?(frame: NSRect, isPreview: Bool) { - engine = NecoSaverEngine(settings: Self.settings(isPreview: isPreview)) + // Seeding the picture wants a screen, and there is no window yet; the main + // display stands in until startAnimation, which is where the real one shows + // up. Preview thumbnails never get further than this. + engine = NecoSaverEngine(settings: Self.settings(isPreview: isPreview, on: nil)) super.init(frame: frame, isPreview: isPreview) // The oneko idle chain counts in 60fps ticks, so ask for that rate. animationTimeInterval = 1.0 / 60.0 @@ -32,10 +35,10 @@ final class NecoSaverView: ScreenSaverView { override func startAnimation() { super.startAnimation() - // Picks up any change made through the configuration sheet. - engine.apply(Self.settings(isPreview: isPreview)) - // By now the view is in a window, so this is the wallpaper of the display - // it actually covers rather than the main one's. + // Picks up any change made through the configuration sheet. By now the view + // is in a window, so the display it actually covers is the one whose + // wallpaper seeds an unset picture. + engine.apply(settings()) loadWallpaper() } @@ -63,7 +66,7 @@ final class NecoSaverView: ScreenSaverView { let controller = ConfigureSheetController { [weak self] saved in guard let self else { return } if saved { - engine.apply(Self.settings(isPreview: isPreview)) + engine.apply(settings()) loadWallpaper() } sheetController = nil @@ -78,8 +81,13 @@ final class NecoSaverView: ScreenSaverView { engine.wallpaper = Wallpaper.matching(engine.settings, on: window?.screen) } - private static func settings(isPreview: Bool) -> NecoSaverSettings { - let settings = NecoSaverSettings.load() + private func settings() -> NecoSaverSettings { + Self.settings(isPreview: isPreview, on: window?.screen) + } + + private static func settings(isPreview: Bool, on screen: NSScreen?) -> NecoSaverSettings { + var settings = NecoSaverSettings.load() + settings.seedPicturePath(with: Wallpaper.desktopPicturePath(for: screen) ?? "") return isPreview ? settings.previewAdjusted : settings } } diff --git a/Sources/Wallpaper.swift b/Sources/Wallpaper.swift index 49c435b..691f5ab 100644 --- a/Sources/Wallpaper.swift +++ b/Sources/Wallpaper.swift @@ -1,13 +1,13 @@ import AppKit -/// The desktop picture of one display, laid out the way the system lays it out. +/// The picture behind the cats, laid out to fill the view. /// -/// Reading the file is allowed: `.saver` bundles run inside the sandbox of +/// A plain path is enough to read one: `.saver` bundles run inside the sandbox of /// `legacyScreenSaver.appex`, which holds a read-only temporary exception for `/` /// (plus `com.apple.security.assets.pictures.read-only`), so a picture anywhere on -/// disk opens without a security-scoped bookmark. Everything here still degrades -/// to `nil` rather than throwing — a video wallpaper, for one, has no still frame -/// to load — and the engine falls back to a plain grey. +/// disk opens with no bookmark and no panel. Everything here degrades to `nil` +/// rather than throwing — a path can name a file that has moved, or a video — and +/// the engine falls back to a plain grey. @MainActor struct Wallpaper { private let image: NSImage @@ -15,29 +15,24 @@ struct Wallpaper { private let allowsClipping: Bool private let fillColor: NSColor - /// The backdrop the settings ask for, or nil to leave the grey showing. + /// The picture the settings ask for, or nil to leave the grey showing. static func matching(_ settings: NecoSaverSettings, on screen: NSScreen?) -> Wallpaper? { - switch settings.backdrop { - case .solidGrey: nil - case .desktopPicture: current(for: screen) - case .customPicture: custom(settings.customPicture) - } + guard settings.pictureEnabled else { return nil } + return picture(atPath: settings.picturePath, on: screen) } - /// Reads the picture currently set on `screen`, or on the main display when - /// the caller has no window to ask. + /// Loads the picture at `path`. /// - /// `NSWorkspace`'s wallpaper API predates WallpaperKit and only reports still - /// pictures. With a dynamic or video wallpaper set it hands back a placeholder - /// (`/System/Library/CoreServices/DefaultDesktop.heic`) and no options at all, - /// which is a picture the user never chose — that is what `customPicture` is - /// for. - static func current(for screen: NSScreen?) -> Wallpaper? { - guard let screen = screen ?? NSScreen.main, - let url = NSWorkspace.shared.desktopImageURL(for: screen), - let image = NSImage(contentsOf: url) else { return nil } + /// Laid out as Fill Screen, except when it happens to be the very picture the + /// desktop is showing — then the desktop's own scaling, clipping and fill + /// colour apply, and the backdrop matches what was on screen a moment earlier. + /// That is the usual case, since the path is seeded with the wallpaper. + static func picture(atPath path: String, on screen: NSScreen?) -> Wallpaper? { + guard !path.isEmpty else { return nil } + let url = URL(fileURLWithPath: path) + guard let image = NSImage(contentsOf: url) else { return nil } - let options = NSWorkspace.shared.desktopImageOptions(for: screen) ?? [:] + let options = desktopOptions(for: url, on: screen) let raw = options[.imageScaling] as? UInt ?? UInt(NSImageScaling.scaleProportionallyUpOrDown.rawValue) return Wallpaper( image: image, @@ -47,45 +42,27 @@ struct Wallpaper { ) } - /// Reads the picture chosen through the Options sheet. + /// The path of the wallpaper on `screen`, or on the main display when the + /// caller has no window to ask. Used to seed the setting and to fill the + /// Options sheet's field. /// - /// There are no system layout options for a picture the desktop knows nothing - /// about, so it is laid out as Fill Screen — the setting most desktops use. - static func custom(_ bookmark: Data?) -> Wallpaper? { - guard let bookmark, let url = url(from: bookmark) else { return nil } - - // No-op for a plain bookmark, which is what a non-sandboxed host produces; - // the read succeeds either way, since legacyScreenSaver.appex can read - // anywhere. Balanced regardless, as the documentation insists. - let scoped = url.startAccessingSecurityScopedResource() - defer { - if scoped { - url.stopAccessingSecurityScopedResource() - } - } - - guard let image = NSImage(contentsOf: url) else { return nil } - return Wallpaper(image: image, scaling: .scaleProportionallyUpOrDown, - allowsClipping: true, fillColor: .black) - } - - /// A bookmark for a picture the user just chose, for `NecoSaverSettings` to - /// keep. Security-scoped where the host is sandboxed enough to make one — the - /// preview app is not — and a plain bookmark otherwise. - static func bookmark(for url: URL) -> Data? { - if let scoped = try? url.bookmarkData(options: .withSecurityScope) { - return scoped - } - return try? url.bookmarkData() + /// `NSWorkspace`'s wallpaper API predates WallpaperKit and only reports still + /// pictures. With a dynamic or video wallpaper set it hands back a placeholder + /// (`/System/Library/CoreServices/DefaultDesktop.heic`), which is a picture the + /// user never chose — hence a field they can correct. + static func desktopPicturePath(for screen: NSScreen?) -> String? { + guard let screen = screen ?? NSScreen.main else { return nil } + return NSWorkspace.shared.desktopImageURL(for: screen)?.path } - /// Resolves a bookmark from either flavour, ignoring staleness: a stale - /// bookmark still names the file, and this side only ever reads. - static func url(from bookmark: Data) -> URL? { - var stale = false - let scoped = try? URL(resolvingBookmarkData: bookmark, options: .withSecurityScope, - relativeTo: nil, bookmarkDataIsStale: &stale) - return scoped ?? (try? URL(resolvingBookmarkData: bookmark, relativeTo: nil, bookmarkDataIsStale: &stale)) + /// How the desktop lays `url` out, when `url` is what the desktop is showing. + private static func desktopOptions( + for url: URL, on screen: NSScreen? + ) -> [NSWorkspace.DesktopImageOptionKey: Any] { + guard let screen = screen ?? NSScreen.main, + let current = NSWorkspace.shared.desktopImageURL(for: screen), + current.standardizedFileURL == url.standardizedFileURL else { return [:] } + return NSWorkspace.shared.desktopImageOptions(for: screen) ?? [:] } func draw(in rect: CGRect, context: CGContext) {