Zum Inhalt

macos/Sources/PerlinLiveWallpaper/AppController.swift

import AppKit

/// Menu-bar controller: owns the preset store, the window manager and the power
/// monitor, and exposes them through an `NSStatusItem` menu.
///
/// Power policy (tied to the power *source*, not Low Power Mode):
///   - On AC  → animate at full rate (30 fps).
///   - On battery → paused by default; or, if "Animate on battery" is on, run at
///     a reduced 10 fps.
final class AppController: NSObject, NSApplicationDelegate {
    private var store: PresetStore!
    private var manager: WallpaperWindowManager?
    private var power: PowerMonitor!
    private var statusItem: NSStatusItem!
    private let editor = EditorWindowController()
    private var activeIndex = 0           // -1 = a custom (editor) look is active
    private var animateOnBattery = false
    private var currentParamsJSON: String?                     // the look currently applied
    private var currentMask: (image: String, invert: Bool)?   // its active image mask
    private var maskFillMode = false                           // false = fit (contain), true = fill (cover)
    private var activeCustomId: String?                        // the applied custom preset, if any

    /// The Preset decoded from the currently-applied JSON, used by the Live
    /// Photo exporter. Falls back to PresetStore.fallback if the JSON is absent
    /// (Metal unavailable path) or cannot be decoded.
    private var currentPreset: Preset {
        guard let json = currentParamsJSON,
              let preset = try? JSONDecoder().decode(Preset.self, from: Data(json.utf8))
        else { return PresetStore.fallback }
        return preset
    }

    private lazy var livePhotoController = LivePhotoExportController(presetProvider: { [weak self] in
        self?.currentPreset ?? PresetStore.fallback
    })

    private static let acFrameRate = 30.0
    private static let batteryFrameRate = 10.0

    func applicationDidFinishLaunching(_ notification: Notification) {
        store = PresetStore()

        // One shared device + compiled pipeline for the whole app.
        guard let gpu = GPUContext(pixelFormat: .bgra8Unorm) else {
            FileHandle.standardError.write(Data("No Metal device — wallpaper disabled\n".utf8))
            setupStatusItem()   // still give the user a way to quit
            return
        }
        // Restore the last-applied custom look, if any; else the first preset.
        let persisted = Persistence.load()
        if persisted != nil { activeIndex = -1 }
        let manager = WallpaperWindowManager(preset: persisted ?? store.presets[0], gpu: gpu)
        self.manager = manager
        // Only trust the raw string when it actually decoded, so the "save"
        // snapshot always matches the rendered look.
        currentParamsJSON = (persisted != nil ? Persistence.loadRaw() : nil) ?? encodeJSON(store.presets[0])
        // The image mask is intentionally NOT restored across launches: the
        // editor starts fresh without it, so a restored mask would "ghost" on the
        // wallpaper with no way to see/edit it. Masks live per-session and inside
        // saved custom presets. Clean up any mask file written by older builds.
        Persistence.clearLegacyMask()

        editor.onParams = { [weak self] json in self?.applyEditorParams(json) }
        editor.onMask = { [weak self] json in self?.applyMask(json) }

        power = PowerMonitor()
        power.onChange = { [weak self] onBattery in self?.applyPowerPolicy(onBattery: onBattery) }
        power.start()

        setupStatusItem()
        applyPowerPolicy(onBattery: power.onBattery)
    }

    /// Live params from the embedded editor: apply to the wallpaper + persist.
    private func applyEditorParams(_ json: String) {
        guard let manager = manager,
              let preset = try? JSONDecoder().decode(Preset.self, from: Data(json.utf8)) else { return }
        manager.apply(preset: preset)
        Persistence.save(json: json)
        currentParamsJSON = json
        // Editing diverges from any built-in/custom selection. Rebuild only on the
        // transition (not on every 250ms poll) to clear the checkmark.
        let needsRebuild = activeIndex != -1 || activeCustomId != nil
        activeIndex = -1
        activeCustomId = nil
        if needsRebuild { rebuildMenu() }
    }

    /// Image mask from the editor: `{image,invert}` to set, `{clear:true}` to clear.
    private func applyMask(_ json: String) {
        guard let manager = manager,
              let obj = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] else { return }
        if obj["clear"] as? Bool == true {
            manager.setMask(base64Png: nil, invert: false, fill: maskFillMode)
            let had = currentMask != nil
            currentMask = nil
            if had { rebuildMenu() }   // drop the "Mask: Fill screen" item
        } else if let image = obj["image"] as? String {
            let invert = obj["invert"] as? Bool ?? false
            manager.setMask(base64Png: image, invert: invert, fill: maskFillMode)
            let isNew = currentMask == nil
            currentMask = (image, invert)
            if isNew { rebuildMenu() }   // show the "Mask: Fill screen" item
        }
    }

    private func encodeJSON(_ preset: Preset) -> String? {
        guard let data = try? JSONEncoder().encode(preset) else { return nil }
        return String(data: data, encoding: .utf8)
    }

    /// Apply the AC/battery policy to the (power) pause reason and the frame rate.
    private func applyPowerPolicy(onBattery: Bool) {
        guard let manager = manager else { return }
        if onBattery && !animateOnBattery {
            manager.setPaused(reason: "power", true)
        } else {
            manager.setFrameRate(onBattery ? AppController.batteryFrameRate : AppController.acFrameRate)
            manager.setPaused(reason: "power", false)
        }
        rebuildMenu()
    }

    private func setupStatusItem() {
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        statusItem.button?.image = NSImage(
            systemSymbolName: "circle.hexagongrid.fill",
            accessibilityDescription: "Perlin Live Wallpaper")
        rebuildMenu()
    }

    private func rebuildMenu() {
        guard statusItem != nil else { return }
        let menu = NSMenu()
        let enabled = manager != nil

        let header = NSMenuItem(title: "Perlin Live Wallpaper", action: nil, keyEquivalent: "")
        header.isEnabled = false
        menu.addItem(header)
        menu.addItem(.separator())

        let edit = NSMenuItem(title: "Open Editor…", action: #selector(openEditor), keyEquivalent: "e")
        edit.target = self
        edit.isEnabled = enabled
        menu.addItem(edit)

        if currentMask != nil {
            let fillItem = NSMenuItem(title: "Mask: Fill screen", action: #selector(toggleMaskFill), keyEquivalent: "")
            fillItem.target = self
            fillItem.state = maskFillMode ? .on : .off
            fillItem.isEnabled = enabled
            fillItem.toolTip = "On: cover the screen (crop). Off: fit the whole image (margins show your wallpaper)."
            menu.addItem(fillItem)
        }
        menu.addItem(.separator())

        if !enabled {
            let unavailable = NSMenuItem(title: "Metal unavailable", action: nil, keyEquivalent: "")
            unavailable.isEnabled = false
            menu.addItem(unavailable)
        }

        for (i, preset) in store.presets.enumerated() {
            let item = NSMenuItem(title: preset.label, action: #selector(selectPreset(_:)), keyEquivalent: "")
            item.target = self
            item.tag = i
            item.state = (i == activeIndex) ? .on : .off
            item.isEnabled = enabled
            menu.addItem(item)
        }
        menu.addItem(.separator())

        // Custom presets (saved looks). Hold Option to reveal a "Remove" action.
        let customs = CustomPresetStore.list()
        if !customs.isEmpty {
            let customHeader = NSMenuItem(title: "Custom", action: nil, keyEquivalent: "")
            customHeader.isEnabled = false
            menu.addItem(customHeader)
            for cp in customs {
                let item = NSMenuItem(title: cp.name, action: #selector(selectCustom(_:)), keyEquivalent: "")
                item.target = self
                item.representedObject = cp.id
                item.state = (activeIndex == -1 && activeCustomId == cp.id) ? .on : .off
                item.isEnabled = enabled
                menu.addItem(item)

                let remove = NSMenuItem(title: "Remove “\(cp.name)”", action: #selector(removeCustom(_:)), keyEquivalent: "")
                remove.target = self
                remove.representedObject = cp.id
                remove.isAlternate = true
                remove.keyEquivalentModifierMask = .option
                remove.isEnabled = enabled
                menu.addItem(remove)
            }
        }
        let saveItem = NSMenuItem(title: "Save Current as…", action: #selector(saveCurrentAs), keyEquivalent: "")
        saveItem.target = self
        saveItem.isEnabled = enabled && (currentParamsJSON != nil)
        menu.addItem(saveItem)

        // Export the current look as an Apple Live Photo pair for use as an
        // iPhone wallpaper (delivered via Photos, or saved to a folder as fallback).
        let exportLP = NSMenuItem(title: "Export Live Photo…", action: #selector(exportLivePhoto), keyEquivalent: "")
        exportLP.target = self
        exportLP.isEnabled = enabled && (currentParamsJSON != nil)
        menu.addItem(exportLP)
        menu.addItem(.separator())

        let pause = NSMenuItem(
            title: (manager?.isManuallyPaused ?? false) ? "Resume" : "Pause",
            action: #selector(togglePause), keyEquivalent: "")
        pause.target = self
        pause.isEnabled = enabled
        menu.addItem(pause)

        let onBattery = NSMenuItem(title: "Animate on battery (10 fps)", action: #selector(toggleAnimateOnBattery), keyEquivalent: "")
        onBattery.target = self
        onBattery.state = animateOnBattery ? .on : .off
        onBattery.isEnabled = enabled
        menu.addItem(onBattery)
        menu.addItem(.separator())

        let quit = NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")
        quit.target = self
        menu.addItem(quit)

        statusItem.menu = menu
    }

    @objc private func selectPreset(_ sender: NSMenuItem) {
        guard let manager = manager else { return }
        activeIndex = sender.tag
        activeCustomId = nil
        let preset = store.presets[activeIndex]
        manager.apply(preset: preset)
        currentParamsJSON = encodeJSON(preset)   // a built-in leaves any active mask untouched
        rebuildMenu()
    }

    // MARK: Custom presets

    @objc private func selectCustom(_ sender: NSMenuItem) {
        guard let id = sender.representedObject as? String,
              let cp = CustomPresetStore.list().first(where: { $0.id == id }) else { return }
        applyCustom(cp)
    }

    private func applyCustom(_ cp: CustomPreset) {
        guard let manager = manager,
              let preset = try? JSONDecoder().decode(Preset.self, from: Data(cp.paramsJSON.utf8)) else { return }
        maskFillMode = cp.maskFill
        manager.apply(preset: preset)
        manager.setMask(base64Png: cp.maskImage, invert: cp.maskInvert, fill: cp.maskFill)   // complete look
        Persistence.save(json: cp.paramsJSON)
        currentParamsJSON = cp.paramsJSON
        currentMask = cp.maskImage.map { ($0, cp.maskInvert) }
        activeIndex = -1
        activeCustomId = cp.id
        rebuildMenu()
    }

    @objc private func removeCustom(_ sender: NSMenuItem) {
        guard let id = sender.representedObject as? String else { return }
        CustomPresetStore.delete(id: id)
        if activeCustomId == id { activeCustomId = nil }
        rebuildMenu()
    }

    @objc private func saveCurrentAs() {
        guard let json = currentParamsJSON else { return }
        NSApp.activate(ignoringOtherApps: true)
        let alert = NSAlert()
        alert.messageText = "Save current look"
        alert.informativeText = "Name this preset:"
        alert.addButton(withTitle: "Save")
        alert.addButton(withTitle: "Cancel")
        let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24))
        field.stringValue = "My Look"
        alert.accessoryView = field
        alert.window.initialFirstResponder = field
        guard alert.runModal() == .alertFirstButtonReturn else { return }
        let name = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !name.isEmpty else { return }
        let id = CustomPresetStore.save(name: name, paramsJSON: json,
                                        maskImage: currentMask?.image, maskInvert: currentMask?.invert ?? false,
                                        maskFill: maskFillMode)
        activeIndex = -1
        activeCustomId = id            // check the just-saved preset
        rebuildMenu()
    }

    @objc private func toggleMaskFill() {
        guard let manager = manager, let mask = currentMask else { return }
        maskFillMode.toggle()
        manager.setMask(base64Png: mask.image, invert: mask.invert, fill: maskFillMode)
        activeCustomId = nil           // the look diverged from any saved preset
        rebuildMenu()
    }

    @objc private func togglePause() {
        guard let manager = manager else { return }
        manager.setPaused(reason: "manual", !manager.isManuallyPaused)
        rebuildMenu()
    }

    @objc private func toggleAnimateOnBattery() {
        animateOnBattery.toggle()
        applyPowerPolicy(onBattery: power?.onBattery ?? false)
    }

    @objc private func openEditor() { editor.show() }

    @objc private func exportLivePhoto() { livePhotoController.present() }

    @objc private func quit() { NSApp.terminate(nil) }
}