Created
March 14, 2026 16:25
-
-
Save GeneralD/531bd74387abf1d602b1cef4c891a720 to your computer and use it in GitHub Desktop.
backdrop — original single-file Swift script (before rearchitecture)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env swift | |
| // Desktop backdrop - lyrics overlay, video wallpaper, and more | |
| // Uses the same lyrics cache as now-playing | |
| // NOTE: Must run with swift interpreter (not swiftc) for MediaRemote access | |
| import AppKit | |
| import AVFoundation | |
| import Observation | |
| import SQLite3 | |
| import SwiftUI | |
| // MARK: - Shared Types (cache-compatible with now-playing) | |
| struct LyricsResult: Codable { | |
| let id: Int? | |
| let trackName: String? | |
| let artistName: String? | |
| let albumName: String? | |
| let duration: Double? | |
| let instrumental: Bool? | |
| let plainLyrics: String? | |
| let syncedLyrics: String? | |
| static let empty = LyricsResult(id: nil, trackName: nil, artistName: nil, albumName: nil, duration: nil, instrumental: nil, plainLyrics: nil, syncedLyrics: nil) | |
| } | |
| struct TimedLine { | |
| let time: TimeInterval | |
| let text: String | |
| } | |
| enum LyricsContent { | |
| case timed([TimedLine]) | |
| case plain([String]) | |
| init?(from result: LyricsResult?) { | |
| if let synced = result?.syncedLyrics.flatMap({ Self.parseSyncedLyrics($0) }), !synced.isEmpty { | |
| self = .timed(synced) | |
| } else if let plain = result?.plainLyrics { | |
| self = .plain(plain.components(separatedBy: "\n")) | |
| } else { | |
| return nil | |
| } | |
| } | |
| private static func parseSyncedLyrics(_ raw: String) -> [TimedLine] { | |
| let re = #/\[(\d+):(\d+(?:\.\d+)?)\]\s*(.*)/# | |
| return raw.split(separator: "\n").compactMap { line in | |
| guard let match = try? re.firstMatch(in: line), | |
| let min = Double(String(match.1)), | |
| let sec = Double(String(match.2)) else { return nil } | |
| return TimedLine(time: min * 60 + sec, | |
| text: String(match.3).trimmingCharacters(in: .whitespaces)) | |
| } | |
| } | |
| } | |
| typealias SearchCandidate = (title: String, artist: String) | |
| // MARK: - MediaRemote | |
| struct NowPlayingInfo { | |
| let title: String? | |
| let artist: String? | |
| let artworkData: Data? | |
| let duration: TimeInterval? | |
| let rawElapsed: TimeInterval? | |
| let playbackRate: Double | |
| let timestamp: Date? | |
| var elapsed: TimeInterval? { | |
| rawElapsed.map { base in | |
| guard let ts = timestamp else { return base } | |
| return base + playbackRate * Date().timeIntervalSince(ts) | |
| } | |
| } | |
| } | |
| class MediaRemoteClient { | |
| private typealias Fn = @convention(c) (DispatchQueue, @escaping (CFDictionary?) -> Void) -> Void | |
| private let function: Fn | |
| init?() { | |
| let path = "/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote" | |
| guard let handle = dlopen(path, RTLD_NOW), | |
| let sym = dlsym(handle, "MRMediaRemoteGetNowPlayingInfo") else { return nil } | |
| function = unsafeBitCast(sym, to: Fn.self) | |
| } | |
| func poll(completion: @escaping (NowPlayingInfo?) -> Void) { | |
| function(DispatchQueue.main) { dict in | |
| completion((dict as? [String: Any]).map(Self.parse)) | |
| } | |
| } | |
| private static func parse(_ dict: [String: Any]) -> NowPlayingInfo { | |
| NowPlayingInfo( | |
| title: dict["kMRMediaRemoteNowPlayingInfoTitle"] as? String, | |
| artist: dict["kMRMediaRemoteNowPlayingInfoArtist"] as? String, | |
| artworkData: dict["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data, | |
| duration: dict["kMRMediaRemoteNowPlayingInfoDuration"] as? TimeInterval, | |
| rawElapsed: dict["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? TimeInterval, | |
| playbackRate: dict["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 1.0, | |
| timestamp: dict["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date | |
| ) | |
| } | |
| } | |
| // MARK: - LRCLIB API | |
| struct LRCLib { | |
| private func httpGet(_ url: URL) -> Data? { | |
| var request = URLRequest(url: url) | |
| request.setValue("now-playing/1.0", forHTTPHeaderField: "User-Agent") | |
| let semaphore = DispatchSemaphore(value: 0) | |
| var responseData: Data? | |
| URLSession.shared.dataTask(with: request) { data, _, _ in | |
| responseData = data | |
| semaphore.signal() | |
| }.resume() | |
| semaphore.wait() | |
| return responseData | |
| } | |
| private func buildURL(_ path: String, queryItems: [URLQueryItem]) -> URL? { | |
| var components = URLComponents(string: "https://lrclib.net/api/\(path)")! | |
| components.queryItems = queryItems | |
| return components.url | |
| } | |
| func get(title: String, artist: String, duration: TimeInterval?) -> LyricsResult? { | |
| let items = [ | |
| URLQueryItem(name: "track_name", value: title), | |
| URLQueryItem(name: "artist_name", value: artist), | |
| duration.map { URLQueryItem(name: "duration", value: String(Int($0))) }, | |
| ].compactMap { $0 } | |
| guard let url = buildURL("get", queryItems: items), let data = httpGet(url) else { return nil } | |
| return try? JSONDecoder().decode(LyricsResult.self, from: data) | |
| } | |
| func search(query: String) -> LyricsResult? { | |
| guard let url = buildURL("search", queryItems: [URLQueryItem(name: "q", value: query)]), | |
| let data = httpGet(url), | |
| let results = try? JSONDecoder().decode([LyricsResult].self, from: data) else { return nil } | |
| return results.first { $0.syncedLyrics != nil } | |
| ?? results.first { $0.plainLyrics != nil } | |
| } | |
| } | |
| // MARK: - Title Parsing | |
| struct TitleParser { | |
| private let noiseWords: Set<String> = [ | |
| "mv", "pv", "official video", "official music video", "music video", | |
| "lyric video", "lyrics video", "the first take", "audio", "official audio", | |
| "full ver.", "full version", "short ver.", "short version", "topic", "vevo", | |
| ] | |
| private let bracketPatterns = [ | |
| "【[^】]*】", "「[^」]*」", "『[^』]*』", | |
| "\\([^)]*\\)", "([^)]*)", "\\[[^\\]]*\\]", | |
| ] | |
| func stripBrackets(_ s: String) -> String { | |
| bracketPatterns.reduce(s) { result, pattern in | |
| result.replacingOccurrences(of: pattern, with: "", options: .regularExpression) | |
| }.trimmingCharacters(in: .whitespaces) | |
| } | |
| func isNoise(_ s: String) -> Bool { | |
| let trimmed = s.trimmingCharacters(in: .whitespaces) | |
| guard !trimmed.isEmpty else { return true } | |
| return noiseWords.contains(trimmed.lowercased()) | |
| } | |
| func splitTitle(_ title: String) -> [String] { | |
| [" - ", " / ", " | ", "|"] | |
| .reduce([title]) { parts, sep in parts.flatMap { $0.components(separatedBy: sep) } } | |
| .map { stripBrackets($0).trimmingCharacters(in: .whitespaces) } | |
| .filter { !isNoise($0) } | |
| } | |
| func generateCandidates(title: String, artist: String) -> [SearchCandidate] { | |
| let parts = splitTitle(title) | |
| let cleaned = stripBrackets(title) | |
| let artistUsable = !isNoise(artist) | |
| let candidates: [SearchCandidate] = [ | |
| artistUsable ? [(cleaned, artist)] : [], | |
| parts.count >= 2 ? [(parts[1], parts[0]), (parts[0], parts[1])] : [], | |
| artistUsable ? parts.filter { $0 != cleaned }.map { ($0, artist) } : [], | |
| parts.count == 1 && !artistUsable ? [(parts[0], "")] : [], | |
| ].flatMap { $0 } | |
| var seen = Set<String>() | |
| return candidates.filter { c in | |
| seen.insert("\(c.title.lowercased())|\(c.artist.lowercased())").inserted | |
| } | |
| } | |
| } | |
| // MARK: - Local Data Store (SQLite Cache) | |
| struct LocalDataStore { | |
| private let db: OpaquePointer? | |
| private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) | |
| private func bindOptionalText(_ stmt: OpaquePointer?, _ index: Int32, _ value: String?) { | |
| _ = value.map { sqlite3_bind_text(stmt, index, $0, -1, transient) } ?? sqlite3_bind_null(stmt, index) | |
| } | |
| private func bindOptionalDouble(_ stmt: OpaquePointer?, _ index: Int32, _ value: Double?) { | |
| _ = value.map { sqlite3_bind_double(stmt, index, $0) } ?? sqlite3_bind_null(stmt, index) | |
| } | |
| private func bindOptionalInt(_ stmt: OpaquePointer?, _ index: Int32, _ value: Int32?) { | |
| _ = value.map { sqlite3_bind_int(stmt, index, $0) } ?? sqlite3_bind_null(stmt, index) | |
| } | |
| init() { | |
| let cacheDir = URL(fileURLWithPath: | |
| ProcessInfo.processInfo.environment["XDG_CACHE_HOME"] ?? "\(NSHomeDirectory())/.cache") | |
| var handle: OpaquePointer? | |
| guard sqlite3_open(cacheDir.appendingPathComponent("lyrics.db").path, &handle) == SQLITE_OK else { | |
| db = nil | |
| return | |
| } | |
| db = handle | |
| // Migrate: drop legacy single-table schema | |
| sqlite3_exec(db, "DROP TABLE IF EXISTS lyrics", nil, nil, nil) | |
| sqlite3_exec(db, """ | |
| CREATE TABLE IF NOT EXISTS lrclib_tracks ( | |
| id INTEGER PRIMARY KEY, | |
| track_name TEXT, | |
| artist_name TEXT, | |
| album_name TEXT, | |
| duration REAL, | |
| instrumental INTEGER, | |
| plain_lyrics TEXT, | |
| synced_lyrics TEXT | |
| ) | |
| """, nil, nil, nil) | |
| sqlite3_exec(db, """ | |
| CREATE TABLE IF NOT EXISTS lyrics_lookup ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| title TEXT NOT NULL, | |
| artist TEXT NOT NULL, | |
| lrclib_id INTEGER NOT NULL REFERENCES lrclib_tracks(id), | |
| UNIQUE (title, artist) | |
| ) | |
| """, nil, nil, nil) | |
| // Migrate: remove legacy now-playing cache directory | |
| try? FileManager.default.removeItem(at: cacheDir.appendingPathComponent("now-playing")) | |
| } | |
| func read(title: String, artist: String) -> LyricsResult? { | |
| guard let db else { return nil } | |
| var stmt: OpaquePointer? | |
| defer { sqlite3_finalize(stmt) } | |
| let sql = """ | |
| SELECT t.id, t.track_name, t.artist_name, t.album_name, t.duration, | |
| t.instrumental, t.plain_lyrics, t.synced_lyrics | |
| FROM lyrics_lookup l JOIN lrclib_tracks t ON l.lrclib_id = t.id | |
| WHERE l.title = ? AND l.artist = ? | |
| """ | |
| guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil } | |
| sqlite3_bind_text(stmt, 1, title, -1, transient) | |
| sqlite3_bind_text(stmt, 2, artist, -1, transient) | |
| guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } | |
| return LyricsResult( | |
| id: sqlite3_column_type(stmt, 0) != SQLITE_NULL ? Int(sqlite3_column_int64(stmt, 0)) : nil, | |
| trackName: sqlite3_column_text(stmt, 1).map(String.init(cString:)), | |
| artistName: sqlite3_column_text(stmt, 2).map(String.init(cString:)), | |
| albumName: sqlite3_column_text(stmt, 3).map(String.init(cString:)), | |
| duration: sqlite3_column_type(stmt, 4) != SQLITE_NULL ? sqlite3_column_double(stmt, 4) : nil, | |
| instrumental: sqlite3_column_type(stmt, 5) != SQLITE_NULL ? sqlite3_column_int(stmt, 5) != 0 : nil, | |
| plainLyrics: sqlite3_column_text(stmt, 6).map(String.init(cString:)), | |
| syncedLyrics: sqlite3_column_text(stmt, 7).map(String.init(cString:)) | |
| ) | |
| } | |
| func write(title: String, artist: String, lyrics: LyricsResult) { | |
| guard let db, let lrclibId = lyrics.id else { return } | |
| var trackStmt: OpaquePointer? | |
| defer { sqlite3_finalize(trackStmt) } | |
| let trackSQL = """ | |
| INSERT OR REPLACE INTO lrclib_tracks | |
| (id, track_name, artist_name, album_name, duration, instrumental, plain_lyrics, synced_lyrics) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?) | |
| """ | |
| guard sqlite3_prepare_v2(db, trackSQL, -1, &trackStmt, nil) == SQLITE_OK else { return } | |
| sqlite3_bind_int64(trackStmt, 1, Int64(lrclibId)) | |
| bindOptionalText(trackStmt, 2, lyrics.trackName) | |
| bindOptionalText(trackStmt, 3, lyrics.artistName) | |
| bindOptionalText(trackStmt, 4, lyrics.albumName) | |
| bindOptionalDouble(trackStmt, 5, lyrics.duration) | |
| bindOptionalInt(trackStmt, 6, lyrics.instrumental.map { $0 ? Int32(1) : Int32(0) }) | |
| bindOptionalText(trackStmt, 7, lyrics.plainLyrics) | |
| bindOptionalText(trackStmt, 8, lyrics.syncedLyrics) | |
| guard sqlite3_step(trackStmt) == SQLITE_DONE else { return } | |
| var lookupStmt: OpaquePointer? | |
| defer { sqlite3_finalize(lookupStmt) } | |
| guard sqlite3_prepare_v2(db, "INSERT OR REPLACE INTO lyrics_lookup (title, artist, lrclib_id) VALUES (?, ?, ?)", -1, &lookupStmt, nil) == SQLITE_OK else { return } | |
| sqlite3_bind_text(lookupStmt, 1, title, -1, transient) | |
| sqlite3_bind_text(lookupStmt, 2, artist, -1, transient) | |
| sqlite3_bind_int64(lookupStmt, 3, Int64(lrclibId)) | |
| sqlite3_step(lookupStmt) | |
| } | |
| } | |
| // MARK: - Remote Data Store (LRCLIB API) | |
| struct RemoteDataStore { | |
| private let api = LRCLib() | |
| private let parser = TitleParser() | |
| func fetch(title: String, artist: String, duration: TimeInterval?) -> LyricsResult? { | |
| let candidates = parser.generateCandidates(title: title, artist: artist) | |
| let getResults = candidates | |
| .filter { !$0.artist.isEmpty } | |
| .compactMap { api.get(title: $0.title, artist: $0.artist, duration: duration) } | |
| .filter { $0.plainLyrics != nil } | |
| // Prefer results with synced lyrics over plain-only | |
| return getResults.first { $0.syncedLyrics != nil } | |
| ?? getResults.first | |
| ?? { | |
| let searchResults = candidates | |
| .map { $0.artist.isEmpty ? $0.title : "\($0.title) \($0.artist)" } | |
| .compactMap { api.search(query: $0) } | |
| .filter { $0.plainLyrics != nil } | |
| return searchResults.first { $0.syncedLyrics != nil } | |
| ?? searchResults.first | |
| }() | |
| } | |
| } | |
| // MARK: - Lyric Repository | |
| struct LyricRepository { | |
| private let local = LocalDataStore() | |
| private let remote = RemoteDataStore() | |
| func fetch(title: String, artist: String, duration: TimeInterval?) -> LyricsResult { | |
| guard !artist.isEmpty else { | |
| return remote.fetch(title: title, artist: artist, duration: duration) ?? .empty | |
| } | |
| if let cached = local.read(title: title, artist: artist) { return cached } | |
| guard let result = remote.fetch(title: title, artist: artist, duration: duration) else { | |
| return .empty | |
| } | |
| local.write(title: title, artist: artist, lyrics: result) | |
| return result | |
| } | |
| } | |
| // MARK: - Process Management | |
| func findOverlayPIDs() -> [Int32] { | |
| let myPID = ProcessInfo.processInfo.processIdentifier | |
| let task = Process() | |
| task.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") | |
| task.arguments = ["-f", "backdrop"] | |
| let pipe = Pipe() | |
| task.standardOutput = pipe | |
| try? task.run() | |
| task.waitUntilExit() | |
| return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)? | |
| .split(separator: "\n") | |
| .compactMap { Int32($0) } | |
| .filter { $0 != myPID } ?? [] | |
| } | |
| func stopExisting() -> Bool { | |
| let pids = findOverlayPIDs() | |
| guard !pids.isEmpty else { return false } | |
| pids.forEach { kill($0, SIGTERM) } | |
| for _ in 0 ..< 20 { | |
| guard pids.contains(where: { kill($0, 0) == 0 }) else { break } | |
| usleep(100_000) | |
| } | |
| pids.filter { kill($0, 0) == 0 }.forEach { kill($0, SIGKILL) } | |
| usleep(100_000) | |
| return true | |
| } | |
| // MARK: - Overlay State | |
| @MainActor @Observable | |
| final class OverlayState { | |
| var title: String? | |
| var artist: String? | |
| var artworkData: Data? | |
| var lyrics: LyricsContent? | |
| var activeLineIndex: Int? | |
| var fetchGeneration: Int = 0 | |
| var screenOrigin: CGPoint = .zero | |
| func reset() { | |
| title = nil; artist = nil; artworkData = nil | |
| lyrics = nil; activeLineIndex = nil | |
| fetchGeneration += 1 | |
| } | |
| } | |
| // MARK: - Configuration | |
| struct TextStyleConfig: Codable { | |
| let font: String? | |
| let size: Double? | |
| let weight: String? | |
| let color: String? | |
| let shadow: String? | |
| let spacing: Double? | |
| init(font: String? = nil, size: Double? = nil, weight: String? = nil, color: String? = nil, shadow: String? = nil, spacing: Double? = nil) { | |
| self.font = font; self.size = size; self.weight = weight | |
| self.color = color; self.shadow = shadow; self.spacing = spacing | |
| } | |
| func merging(over base: TextStyleConfig) -> TextStyleConfig { | |
| .init( | |
| font: font ?? base.font, | |
| size: size ?? base.size, | |
| weight: weight ?? base.weight, | |
| color: color ?? base.color, | |
| shadow: shadow ?? base.shadow, | |
| spacing: spacing ?? base.spacing | |
| ) | |
| } | |
| } | |
| struct ResolvedTextStyle { | |
| let spacing: Double | |
| let swiftFont: Font | |
| let swiftColor: Color | |
| let swiftShadow: Color | |
| let lineHeight: Double | |
| init(from config: TextStyleConfig) { | |
| let font = config.font ?? "Zen Maru Gothic" | |
| let size = config.size ?? 12 | |
| let weight = config.weight ?? "regular" | |
| spacing = config.spacing ?? 6 | |
| let fontWeight: Font.Weight = switch weight.lowercased() { | |
| case "ultralight": .ultraLight | |
| case "thin": .thin | |
| case "light": .light | |
| case "medium": .medium | |
| case "semibold": .semibold | |
| case "bold": .bold | |
| case "heavy": .heavy | |
| case "black": .black | |
| default: .regular | |
| } | |
| let available = NSFontManager.shared.availableFontFamilies.contains(font) | |
| swiftFont = available | |
| ? Font.custom(font, size: size).weight(fontWeight) | |
| : Font.system(size: size, weight: fontWeight) | |
| swiftColor = parseHexColor(config.color ?? "#FFFFFFD9") | |
| swiftShadow = parseHexColor(config.shadow ?? "#000000E6") | |
| let nsFont = available ? NSFont(name: font, size: size) : nil | |
| let fallback = NSFont.systemFont(ofSize: size) | |
| lineHeight = ceil((nsFont?.ascender ?? fallback.ascender) - (nsFont?.descender ?? fallback.descender) | |
| + (nsFont?.leading ?? fallback.leading)) + spacing * 2 | |
| } | |
| } | |
| struct TextConfig: Codable { | |
| let `default`: TextStyleConfig | |
| let title: TextStyleConfig? | |
| let artist: TextStyleConfig? | |
| let lyric: TextStyleConfig? | |
| let highlight: [String] | |
| private static let titleDefaults: TextStyleConfig = .init(size: 18, weight: "bold") | |
| private static let artistDefaults: TextStyleConfig = .init(weight: "medium") | |
| var resolvedTitle: ResolvedTextStyle { .init(from: (title ?? .init()).merging(over: Self.titleDefaults.merging(over: `default`))) } | |
| var resolvedArtist: ResolvedTextStyle { .init(from: (artist ?? .init()).merging(over: Self.artistDefaults.merging(over: `default`))) } | |
| var resolvedLyric: ResolvedTextStyle { .init(from: (lyric ?? .init()).merging(over: `default`)) } | |
| var highlightStyle: AnyShapeStyle { | |
| let colors = highlight.map(parseHexColor) | |
| guard colors.count > 1 else { | |
| return .init(colors.first ?? .white) | |
| } | |
| let stops = colors.enumerated().map { i, color in | |
| Gradient.Stop(color: color, location: CGFloat(i) / CGFloat(colors.count - 1)) | |
| } | |
| return .init(LinearGradient(stops: stops, startPoint: .leading, endPoint: .trailing)) | |
| } | |
| init(`default`: TextStyleConfig = .init(), title: TextStyleConfig? = nil, artist: TextStyleConfig? = nil, lyric: TextStyleConfig? = nil, highlight: [String] = ["#B8942DFF", "#EDCF73FF", "#FFEB99FF", "#CCA64DFF", "#A68038FF"]) { | |
| self.default = `default` | |
| self.title = title | |
| self.artist = artist | |
| self.lyric = lyric | |
| self.highlight = highlight | |
| } | |
| enum CodingKeys: String, CodingKey { | |
| case `default`, title, artist, lyric, highlight | |
| } | |
| } | |
| struct ArtworkConfig: Codable { | |
| let size: CGFloat | |
| init(size: CGFloat = 96) { self.size = size } | |
| } | |
| struct RippleConfig: Codable { | |
| let color: String | |
| let radius: Double | |
| let duration: Double | |
| let idle: Double | |
| var resolvedColor: NSColor { NSColor(parseHexColor(color)).usingColorSpace(.deviceRGB) ?? .white } | |
| init(color: String = "#AAAAFFFF", radius: Double = 60, duration: Double = 0.6, idle: Double = 1) { | |
| self.color = color | |
| self.radius = radius | |
| self.duration = duration | |
| self.idle = idle | |
| } | |
| } | |
| func parseHexColor(_ hex: String) -> Color { | |
| let h = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex | |
| guard h.count == 6 || h.count == 8, | |
| let value = UInt64(h, radix: 16) else { return .white } | |
| let r, g, b, a: Double | |
| switch h.count { | |
| case 8: | |
| r = Double((value >> 24) & 0xFF) / 255 | |
| g = Double((value >> 16) & 0xFF) / 255 | |
| b = Double((value >> 8) & 0xFF) / 255 | |
| a = Double(value & 0xFF) / 255 | |
| default: | |
| r = Double((value >> 16) & 0xFF) / 255 | |
| g = Double((value >> 8) & 0xFF) / 255 | |
| b = Double(value & 0xFF) / 255 | |
| a = 1 | |
| } | |
| return Color(red: r, green: g, blue: b, opacity: a) | |
| } | |
| enum ScreenSelector: Codable, Equatable { | |
| case main | |
| case primary | |
| case index(Int) | |
| case smallest | |
| case largest | |
| case match | |
| init(from decoder: Decoder) throws { | |
| let container = try decoder.singleValueContainer() | |
| if let n = try? container.decode(Int.self) { | |
| self = .index(n) | |
| return | |
| } | |
| let s = try container.decode(String.self) | |
| switch s.lowercased() { | |
| case "main": self = .main | |
| case "primary": self = .primary | |
| case "smallest": self = .smallest | |
| case "largest": self = .largest | |
| case "match": self = .match | |
| default: self = .main | |
| } | |
| } | |
| func encode(to encoder: Encoder) throws { | |
| var container = encoder.singleValueContainer() | |
| switch self { | |
| case .main: try container.encode("main") | |
| case .primary: try container.encode("primary") | |
| case .index(let n): try container.encode(n) | |
| case .smallest: try container.encode("smallest") | |
| case .largest: try container.encode("largest") | |
| case .match: try container.encode("match") | |
| } | |
| } | |
| } | |
| func resolveScreen(selector: ScreenSelector, wallpaperURL: URL?) -> NSScreen { | |
| let screens = NSScreen.screens | |
| guard !screens.isEmpty else { return NSScreen.main ?? NSScreen() } | |
| switch selector { | |
| case .main: | |
| return .main ?? screens[0] | |
| case .primary: | |
| return screens[0] | |
| case .index(let n): | |
| return n < screens.count ? screens[n] : screens[0] | |
| case .smallest: | |
| return screens.min { $0.frame.width * $0.frame.height < $1.frame.width * $1.frame.height } ?? screens[0] | |
| case .largest: | |
| return screens.max { $0.frame.width * $0.frame.height < $1.frame.width * $1.frame.height } ?? screens[0] | |
| case .match: | |
| guard let url = wallpaperURL else { return .main ?? screens[0] } | |
| let sem = DispatchSemaphore(value: 0) | |
| var videoAspect: CGFloat? | |
| Task { | |
| defer { sem.signal() } | |
| guard let track = try? await AVURLAsset(url: url).loadTracks(withMediaType: .video).first, | |
| let size = try? await track.load(.naturalSize), | |
| let transform = try? await track.load(.preferredTransform) else { return } | |
| let s = size.applying(transform) | |
| videoAspect = abs(s.width) / abs(s.height) | |
| } | |
| sem.wait() | |
| guard let videoAspect else { return .main ?? screens[0] } | |
| return screens.min { a, b in | |
| let aa = a.frame.width / a.frame.height | |
| let ba = b.frame.width / b.frame.height | |
| return abs(aa - videoAspect) < abs(ba - videoAspect) | |
| } ?? screens[0] | |
| } | |
| } | |
| struct BackdropConfig: Codable { | |
| let text: TextConfig | |
| let artwork: ArtworkConfig | |
| let ripple: RippleConfig | |
| let screen: ScreenSelector | |
| let wallpaper: String? | |
| let configDir: String? | |
| init(from decoder: Decoder) throws { | |
| let container = try decoder.container(keyedBy: CodingKeys.self) | |
| text = try container.decodeIfPresent(TextConfig.self, forKey: .text) ?? .init() | |
| artwork = try container.decodeIfPresent(ArtworkConfig.self, forKey: .artwork) ?? .init() | |
| ripple = try container.decodeIfPresent(RippleConfig.self, forKey: .ripple) ?? .init() | |
| screen = try container.decodeIfPresent(ScreenSelector.self, forKey: .screen) ?? .main | |
| wallpaper = try container.decodeIfPresent(String.self, forKey: .wallpaper) | |
| configDir = nil | |
| } | |
| var wallpaperURL: URL? { | |
| guard let wallpaper else { return nil } | |
| guard !wallpaper.hasPrefix("/") else { return URL(fileURLWithPath: wallpaper) } | |
| return configDir.map { URL(fileURLWithPath: $0).appendingPathComponent(wallpaper) } | |
| } | |
| init(text: TextConfig = .init(), artwork: ArtworkConfig = .init(), ripple: RippleConfig = .init(), screen: ScreenSelector = .main, wallpaper: String? = nil, configDir: String? = nil) { | |
| self.text = text | |
| self.artwork = artwork | |
| self.ripple = ripple | |
| self.screen = screen | |
| self.wallpaper = wallpaper | |
| self.configDir = configDir | |
| } | |
| enum CodingKeys: String, CodingKey { | |
| case text, artwork, ripple, screen, wallpaper | |
| } | |
| static func load() -> BackdropConfig { | |
| let home = NSHomeDirectory() | |
| let xdgConfig = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] ?? "\(home)/.config" | |
| let candidates = [ | |
| "\(xdgConfig)/backdrop/config.json", | |
| "\(home)/.backdrop/config.json", | |
| ] | |
| guard let path = candidates.first(where: { FileManager.default.fileExists(atPath: $0) }), | |
| let data = try? Data(contentsOf: URL(fileURLWithPath: path)), | |
| let decoded = try? JSONDecoder().decode(BackdropConfig.self, from: data) | |
| else { return .init() } | |
| return BackdropConfig( | |
| text: decoded.text, artwork: decoded.artwork, ripple: decoded.ripple, | |
| screen: decoded.screen, wallpaper: decoded.wallpaper, | |
| configDir: (path as NSString).deletingLastPathComponent | |
| ) | |
| } | |
| } | |
| var config = BackdropConfig.load() | |
| // MARK: - Column Layout | |
| struct ColumnLayout { | |
| let columnWidth: Double | |
| let columnGap: Double | |
| let maxColumns: Int | |
| let linesPerColumn: Int | |
| init(width: Double, lyricsHeight: Double) { | |
| columnGap = round(width * 0.03) | |
| columnWidth = round(width * 0.28) | |
| maxColumns = max(1, Int((width + columnGap) / (columnWidth + columnGap))) | |
| linesPerColumn = max(1, Int(lyricsHeight / config.text.resolvedLyric.lineHeight)) | |
| } | |
| func columnsNeeded(for lineCount: Int) -> Int { | |
| min(maxColumns, max(1, (lineCount + linesPerColumn - 1) / linesPerColumn)) | |
| } | |
| } | |
| // MARK: - Ripple Effect | |
| @MainActor @Observable | |
| final class RippleState { | |
| struct Ripple: Identifiable { | |
| let id = UUID() | |
| let position: CGPoint | |
| let startTime: Date | |
| let idle: Bool | |
| let hueShift: Double = .random(in: -0.15...0.15) | |
| } | |
| var ripples: [Ripple] = [] | |
| private var currentPosition: CGPoint = .zero | |
| private var lastRipplePosition: CGPoint = .zero | |
| private var lastIdleRipple: Date = .now | |
| func update(screenPoint: CGPoint) { | |
| currentPosition = screenPoint | |
| let distance = hypot(screenPoint.x - lastRipplePosition.x, screenPoint.y - lastRipplePosition.y) | |
| guard distance > 40 else { return } | |
| lastRipplePosition = screenPoint | |
| lastIdleRipple = .now | |
| ripples.append(.init(position: screenPoint, startTime: .now, idle: false)) | |
| cleanup() | |
| } | |
| func idle() { | |
| guard config.ripple.idle > 0, | |
| Date.now.timeIntervalSince(lastIdleRipple) > config.ripple.idle else { return } | |
| lastIdleRipple = .now | |
| ripples.append(.init(position: currentPosition, startTime: .now, idle: true)) | |
| cleanup() | |
| } | |
| private func cleanup() { | |
| ripples.removeAll { Date.now.timeIntervalSince($0.startTime) > config.ripple.duration * 3 } | |
| } | |
| } | |
| @MainActor | |
| struct RippleView: View { | |
| let rippleState: RippleState | |
| let screenOrigin: CGPoint | |
| private let baseColor = config.ripple.resolvedColor | |
| var body: some View { | |
| TimelineView(.animation) { timeline in | |
| Canvas { context, size in | |
| let now = timeline.date | |
| for ripple in rippleState.ripples { | |
| let elapsed = now.timeIntervalSince(ripple.startTime) | |
| let rc = config.ripple | |
| let dur = ripple.idle ? rc.duration * 3 : rc.duration | |
| guard elapsed < dur else { continue } | |
| let t = elapsed / dur | |
| let easeOut = 1 - (1 - t) * (1 - t) | |
| let radius = easeOut * rc.radius | |
| let shifted = Color( | |
| hue: (baseColor.hueComponent + ripple.hueShift).truncatingRemainder(dividingBy: 1), | |
| saturation: baseColor.saturationComponent, | |
| brightness: baseColor.brightnessComponent, | |
| opacity: baseColor.alphaComponent * pow(1 - t, 0.6) | |
| ) | |
| let x = ripple.position.x - screenOrigin.x | |
| let y = size.height - (ripple.position.y - screenOrigin.y) | |
| let rect = CGRect(x: x - radius, y: y - radius, width: radius * 2, height: radius * 2) | |
| context.stroke( | |
| Path(ellipseIn: rect), | |
| with: .color(shifted), | |
| lineWidth: 2.5 | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // MARK: - SwiftUI Views | |
| @MainActor | |
| struct LyricLineView: View { | |
| let text: String | |
| let isActive: Bool | |
| private let style = config.text.resolvedLyric | |
| var body: some View { | |
| Text(text.isEmpty ? " " : text) | |
| .font(style.swiftFont) | |
| .foregroundStyle(isActive ? config.text.highlightStyle : .init(style.swiftColor)) | |
| .opacity(isActive ? 1.0 : 0.7) | |
| .scaleEffect(isActive ? 1.03 : 1.0, anchor: .leading) | |
| .shadow(color: style.swiftShadow, radius: 5, x: 0, y: 1) | |
| .frame(maxWidth: .infinity, alignment: .leading) | |
| .padding(.vertical, style.spacing) | |
| .animation(.easeInOut(duration: 0.3), value: isActive) | |
| } | |
| } | |
| @MainActor | |
| struct HeaderView: View { | |
| let title: String? | |
| let artist: String? | |
| let artworkData: Data? | |
| private let titleStyle = config.text.resolvedTitle | |
| private let artistStyle = config.text.resolvedArtist | |
| private var trackID: String { "\(title ?? "")\n\(artist ?? "")" } | |
| var body: some View { | |
| HStack(spacing: 24) { | |
| if let artworkData, let image = NSImage(data: artworkData) { | |
| Image(nsImage: image) | |
| .resizable() | |
| .aspectRatio(contentMode: .fit) | |
| .frame(height: config.artwork.size) | |
| .clipShape(RoundedRectangle(cornerRadius: 6)) | |
| } | |
| VStack(alignment: .leading, spacing: titleStyle.spacing) { | |
| if let title { | |
| Text(title) | |
| .font(titleStyle.swiftFont) | |
| .foregroundStyle(titleStyle.swiftColor) | |
| .shadow(color: titleStyle.swiftShadow, radius: 5, x: 0, y: 1) | |
| .lineLimit(1) | |
| } | |
| if let artist { | |
| Text(artist) | |
| .font(artistStyle.swiftFont) | |
| .foregroundStyle(artistStyle.swiftColor) | |
| .shadow(color: artistStyle.swiftShadow, radius: 5, x: 0, y: 1) | |
| .lineLimit(1) | |
| } | |
| } | |
| Spacer() | |
| } | |
| .id(trackID) | |
| .transition(.opacity) | |
| .animation(.easeInOut(duration: 0.5), value: trackID) | |
| } | |
| } | |
| @MainActor | |
| struct LyricsOverlayView: View { | |
| let state: OverlayState | |
| let rippleState: RippleState | |
| private struct Column: Identifiable { | |
| let id: Int | |
| let entries: [(index: Int, text: String)] | |
| let highlightIndex: Int? | |
| } | |
| private func columns(layout: ColumnLayout) -> [Column] { | |
| let texts: [String] | |
| let highlightIndex: Int? | |
| switch state.lyrics { | |
| case let .timed(lines): | |
| texts = lines.map(\.text) | |
| highlightIndex = state.activeLineIndex | |
| case let .plain(lines): | |
| texts = lines | |
| highlightIndex = nil | |
| case nil: | |
| return [] | |
| } | |
| let lpc = layout.linesPerColumn | |
| let count = layout.columnsNeeded(for: texts.count) | |
| return (0 ..< count).map { col in | |
| let start = col * lpc | |
| let end = min(start + lpc, texts.count) | |
| let entries = (start ..< end).map { i in (index: i, text: texts[i]) } | |
| return Column(id: col, entries: entries, highlightIndex: highlightIndex) | |
| } | |
| } | |
| var body: some View { | |
| ZStack { | |
| RippleView(rippleState: rippleState, screenOrigin: state.screenOrigin) | |
| VStack(alignment: .leading, spacing: 32) { | |
| HeaderView(title: state.title, artist: state.artist, artworkData: state.artworkData) | |
| GeometryReader { geo in | |
| let layout = ColumnLayout(width: geo.size.width, lyricsHeight: geo.size.height) | |
| HStack(alignment: .top, spacing: layout.columnGap) { | |
| ForEach(columns(layout: layout)) { column in | |
| VStack(alignment: .leading, spacing: 0) { | |
| ForEach(column.entries, id: \.index) { entry in | |
| LyricLineView(text: entry.text, isActive: entry.index == column.highlightIndex) | |
| } | |
| Spacer() | |
| } | |
| .frame(width: layout.columnWidth) | |
| } | |
| } | |
| } | |
| } | |
| .padding(48) | |
| .padding(.bottom, 32) | |
| .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) | |
| } | |
| } | |
| } | |
| // MARK: - Overlay Window | |
| @MainActor | |
| class OverlayWindow { | |
| private let window: NSWindow | |
| private let mediaRemote: MediaRemoteClient | |
| private let repository = LyricRepository() | |
| private let state = OverlayState() | |
| private let rippleState = RippleState() | |
| private var loopObserver: NSObjectProtocol? | |
| private var mouseMonitor: Any? | |
| private var screenObserver: NSObjectProtocol? | |
| private var sleepObserver: NSObjectProtocol? | |
| private var wakeObserver: NSObjectProtocol? | |
| private let hostingView: NSHostingView<LyricsOverlayView> | |
| private let hasWallpaper: Bool | |
| private var lastTrackKey: (String?, String?) = (nil, nil) | |
| private var latestInfo: NowPlayingInfo? | |
| private var displayLink: CADisplayLink? | |
| private var queuePlayer: AVPlayer? | |
| private static func resolvedFrames(hasWallpaper: Bool) -> (window: NSRect, hosting: NSRect, origin: CGPoint) { | |
| let screen = resolveScreen(selector: config.screen, wallpaperURL: config.wallpaperURL) | |
| let visibleFrame = screen.visibleFrame | |
| let fullFrame = screen.frame | |
| let windowRect = hasWallpaper ? fullFrame : visibleFrame | |
| let hostingFrame = NSRect( | |
| x: visibleFrame.minX - windowRect.minX, | |
| y: visibleFrame.minY - windowRect.minY, | |
| width: visibleFrame.width, | |
| height: visibleFrame.height | |
| ) | |
| return (windowRect, hostingFrame, CGPoint(x: visibleFrame.minX, y: visibleFrame.minY)) | |
| } | |
| init(mediaRemote: MediaRemoteClient) { | |
| self.mediaRemote = mediaRemote | |
| hasWallpaper = config.wallpaperURL != nil | |
| let frames = Self.resolvedFrames(hasWallpaper: hasWallpaper) | |
| let window = NSWindow( | |
| contentRect: frames.window, | |
| styleMask: .borderless, | |
| backing: .buffered, | |
| defer: false | |
| ) | |
| window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1) | |
| window.backgroundColor = hasWallpaper ? .black : .clear | |
| window.isOpaque = false | |
| window.ignoresMouseEvents = true | |
| window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] | |
| state.screenOrigin = frames.origin | |
| let hostingView = NSHostingView(rootView: LyricsOverlayView( | |
| state: state, | |
| rippleState: rippleState | |
| )) | |
| hostingView.frame = frames.hosting | |
| self.hostingView = hostingView | |
| if let wallpaperURL = config.wallpaperURL { | |
| let containerView = NSView(frame: NSRect(origin: .zero, size: frames.window.size)) | |
| let player = AVPlayer(url: wallpaperURL) | |
| player.isMuted = true | |
| player.preventsDisplaySleepDuringVideoPlayback = false | |
| player.actionAtItemEnd = .none | |
| queuePlayer = player | |
| loopObserver = NotificationCenter.default.addObserver( | |
| forName: .AVPlayerItemDidPlayToEndTime, | |
| object: player.currentItem, queue: .main | |
| ) { [weak player] _ in | |
| player?.seek(to: .zero) | |
| player?.play() | |
| } | |
| let playerLayer = AVPlayerLayer(player: player) | |
| playerLayer.frame = containerView.bounds | |
| playerLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] | |
| playerLayer.videoGravity = .resizeAspectFill | |
| containerView.wantsLayer = true | |
| containerView.layer?.addSublayer(playerLayer) | |
| containerView.addSubview(hostingView) | |
| window.contentView = containerView | |
| player.play() | |
| } else { | |
| window.contentView = hostingView | |
| } | |
| self.window = window | |
| window.orderFront(nil) | |
| mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .mouseMoved) { [rippleState] event in | |
| rippleState.update(screenPoint: NSEvent.mouseLocation) | |
| } | |
| screenObserver = NotificationCenter.default.addObserver( | |
| forName: NSApplication.didChangeScreenParametersNotification, | |
| object: nil, queue: .main | |
| ) { [weak self] _ in | |
| MainActor.assumeIsolated { self?.recalculateLayout() } | |
| } | |
| let ws = NSWorkspace.shared.notificationCenter | |
| sleepObserver = ws.addObserver( | |
| forName: NSWorkspace.screensDidSleepNotification, | |
| object: nil, queue: .main | |
| ) { [weak self] _ in | |
| MainActor.assumeIsolated { self?.queuePlayer?.pause() } | |
| } | |
| wakeObserver = ws.addObserver( | |
| forName: NSWorkspace.screensDidWakeNotification, | |
| object: nil, queue: .main | |
| ) { [weak self] _ in | |
| MainActor.assumeIsolated { self?.queuePlayer?.play() } | |
| } | |
| } | |
| private func recalculateLayout() { | |
| let frames = Self.resolvedFrames(hasWallpaper: hasWallpaper) | |
| window.setFrame(frames.window, display: false) | |
| state.screenOrigin = frames.origin | |
| hostingView.frame = frames.hosting | |
| if let containerView = window.contentView, containerView !== hostingView { | |
| containerView.frame = NSRect(origin: .zero, size: frames.window.size) | |
| } | |
| } | |
| func pollMediaRemote() { | |
| mediaRemote.poll { [self] info in | |
| guard let info else { return clearIfNeeded() } | |
| latestInfo = info | |
| updateTrack(from: info) | |
| updateActiveLineIndex(from: info) | |
| } | |
| } | |
| func updateUI() { | |
| rippleState.idle() | |
| guard let info = latestInfo else { return } | |
| updateActiveLineIndex(from: info) | |
| } | |
| func startDisplayLink() { | |
| let dl = window.displayLink(target: self, selector: #selector(displayLinkFired)) | |
| dl.add(to: .main, forMode: .common) | |
| displayLink = dl | |
| } | |
| func stopDisplayLink() { | |
| displayLink?.invalidate() | |
| displayLink = nil | |
| } | |
| @objc private func displayLinkFired(_ link: CADisplayLink) { | |
| updateUI() | |
| } | |
| private func clearIfNeeded() { | |
| guard lastTrackKey != (nil, nil) else { return } | |
| lastTrackKey = (nil, nil) | |
| state.reset() | |
| } | |
| private func updateTrack(from info: NowPlayingInfo) { | |
| if info.artworkData != state.artworkData { state.artworkData = info.artworkData } | |
| let trackKey = (info.title, info.artist) | |
| guard trackKey != lastTrackKey else { return } | |
| lastTrackKey = trackKey | |
| state.title = info.title | |
| state.artist = info.artist | |
| state.activeLineIndex = nil | |
| state.fetchGeneration += 1 | |
| let generation = state.fetchGeneration | |
| let repo = repository | |
| DispatchQueue.global().async { | |
| let result: LyricsResult? = { | |
| guard let title = info.title, let artist = info.artist else { return nil } | |
| return repo.fetch(title: title, artist: artist, duration: info.duration) | |
| }() | |
| let content = LyricsContent(from: result) | |
| DispatchQueue.main.async { [self] in | |
| guard generation == state.fetchGeneration else { return } | |
| state.title = result?.trackName ?? info.title | |
| state.artist = result?.artistName ?? info.artist | |
| state.lyrics = content | |
| state.activeLineIndex = nil | |
| } | |
| } | |
| } | |
| private func updateActiveLineIndex(from info: NowPlayingInfo) { | |
| guard case let .timed(lines) = state.lyrics else { return } | |
| let index = info.elapsed.flatMap { elapsed in lines.lastIndex { $0.time <= elapsed } } | |
| guard index != state.activeLineIndex else { return } | |
| state.activeLineIndex = index | |
| } | |
| func close() { | |
| stopDisplayLink() | |
| queuePlayer?.pause() | |
| loopObserver.map(NotificationCenter.default.removeObserver) | |
| mouseMonitor.map(NSEvent.removeMonitor) | |
| screenObserver.map(NotificationCenter.default.removeObserver) | |
| let ws = NSWorkspace.shared.notificationCenter | |
| sleepObserver.map(ws.removeObserver) | |
| wakeObserver.map(ws.removeObserver) | |
| window.orderOut(nil) | |
| window.close() | |
| } | |
| } | |
| // MARK: - CLI | |
| let command = CommandLine.arguments.dropFirst().first ?? "--help" | |
| switch command { | |
| case "--help", "-h": | |
| print(""" | |
| Usage: backdrop <command> | |
| Commands: | |
| start Start the overlay as a background process | |
| stop Stop the running overlay | |
| restart Stop and start the overlay | |
| service install Register as login item (LaunchAgent) | |
| service uninstall Remove login item | |
| completion <shell> Output shell completion script (zsh, bash) | |
| """) | |
| exit(0) | |
| case "completion": | |
| let shell = CommandLine.arguments.dropFirst(2).first | |
| switch shell { | |
| case "zsh": | |
| print(""" | |
| #compdef backdrop | |
| _backdrop() { | |
| local -a commands | |
| commands=( | |
| 'start:Start the overlay as a background process' | |
| 'stop:Stop the running overlay' | |
| 'restart:Stop and start the overlay' | |
| 'service:Manage login item service' | |
| 'completion:Output shell completion script' | |
| ) | |
| local -a service_commands | |
| service_commands=( | |
| 'install:Register as login item (LaunchAgent)' | |
| 'uninstall:Remove login item' | |
| ) | |
| local -a completion_commands | |
| completion_commands=( | |
| 'zsh:Output zsh completion script' | |
| 'bash:Output bash completion script' | |
| ) | |
| _arguments -C '1:command:->cmd' '*::arg:->args' | |
| case $state in | |
| cmd) | |
| _describe 'command' commands | |
| ;; | |
| args) | |
| case $words[1] in | |
| service) | |
| _describe 'subcommand' service_commands | |
| ;; | |
| completion) | |
| _describe 'shell' completion_commands | |
| ;; | |
| esac | |
| ;; | |
| esac | |
| } | |
| _backdrop "$@" | |
| """) | |
| case "bash": | |
| print(""" | |
| _backdrop() { | |
| local cur prev commands service_cmds completion_cmds | |
| COMPREPLY=() | |
| cur="${COMP_WORDS[COMP_CWORD]}" | |
| prev="${COMP_WORDS[COMP_CWORD-1]}" | |
| commands="start stop restart service completion" | |
| service_cmds="install uninstall" | |
| completion_cmds="zsh bash" | |
| case "$prev" in | |
| backdrop) | |
| COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) | |
| ;; | |
| service) | |
| COMPREPLY=( $(compgen -W "$service_cmds" -- "$cur") ) | |
| ;; | |
| completion) | |
| COMPREPLY=( $(compgen -W "$completion_cmds" -- "$cur") ) | |
| ;; | |
| esac | |
| } | |
| complete -F _backdrop backdrop | |
| """) | |
| default: | |
| fputs("Usage: backdrop completion <zsh|bash>\n", stderr) | |
| exit(1) | |
| } | |
| exit(0) | |
| case "service": | |
| let subcommand = CommandLine.arguments.dropFirst(2).first | |
| let label = "com.user.backdrop" | |
| let plistPath = FileManager.default.homeDirectoryForCurrentUser | |
| .appendingPathComponent("Library/LaunchAgents/\(label).plist") | |
| switch subcommand { | |
| case "install": | |
| let scriptPath = URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL.path | |
| let plist = """ | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" \ | |
| "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"> | |
| <dict> | |
| <key>Label</key><string>\(label)</string> | |
| <key>ProgramArguments</key> | |
| <array> | |
| <string>\(scriptPath)</string> | |
| <string>__daemon</string> | |
| </array> | |
| <key>RunAtLoad</key><true/> | |
| </dict> | |
| </plist> | |
| """ | |
| _ = stopExisting() | |
| // bootout first in case already registered | |
| let uid = getuid() | |
| let target = "gui/\(uid)" | |
| let bootout = Process() | |
| bootout.executableURL = URL(fileURLWithPath: "/bin/launchctl") | |
| bootout.arguments = ["bootout", "\(target)/\(label)"] | |
| bootout.standardError = FileHandle.nullDevice | |
| try? bootout.run() | |
| bootout.waitUntilExit() | |
| try! plist.write(to: plistPath, atomically: true, encoding: .utf8) | |
| let bootstrap = Process() | |
| bootstrap.executableURL = URL(fileURLWithPath: "/bin/launchctl") | |
| bootstrap.arguments = ["bootstrap", target, plistPath.path] | |
| try! bootstrap.run() | |
| bootstrap.waitUntilExit() | |
| guard bootstrap.terminationStatus == 0 else { | |
| fputs("Failed to start service (launchctl bootstrap exit \(bootstrap.terminationStatus))\n", stderr) | |
| exit(1) | |
| } | |
| print("Installed and started: \(plistPath.path)") | |
| case "uninstall": | |
| guard FileManager.default.fileExists(atPath: plistPath.path) else { | |
| print("Not installed"); exit(0) | |
| } | |
| let uid = getuid() | |
| let bootout = Process() | |
| bootout.executableURL = URL(fileURLWithPath: "/bin/launchctl") | |
| bootout.arguments = ["bootout", "gui/\(uid)/\(label)"] | |
| bootout.standardError = FileHandle.nullDevice | |
| try? bootout.run() | |
| bootout.waitUntilExit() | |
| _ = stopExisting() | |
| try! FileManager.default.removeItem(at: plistPath) | |
| print("Uninstalled") | |
| default: | |
| fputs("Usage: backdrop service <install|uninstall>\n", stderr) | |
| exit(1) | |
| } | |
| exit(0) | |
| case "stop": | |
| print(stopExisting() ? "Stopped" : "Not running") | |
| exit(0) | |
| case "restart": | |
| _ = stopExisting() | |
| fallthrough | |
| case "start": | |
| if command == "start", !findOverlayPIDs().isEmpty { print("Already running"); exit(0) } | |
| // Launch self as background daemon | |
| let script = CommandLine.arguments[0] | |
| let task = Process() | |
| task.executableURL = URL(fileURLWithPath: "/usr/bin/env") | |
| task.arguments = ["swift", script, "__daemon"] | |
| task.standardOutput = FileHandle.nullDevice | |
| task.standardError = FileHandle.nullDevice | |
| guard let _ = try? task.run() else { | |
| fputs("Failed to start overlay\n", stderr) | |
| exit(1) | |
| } | |
| print("Overlay started (PID \(task.processIdentifier))") | |
| exit(0) | |
| // Internal: swift scripts can't fork(), so "start" re-launches itself with this arg to run as a background daemon | |
| case "__daemon": | |
| break | |
| default: | |
| fputs("Unknown command: \(command)\n", stderr) | |
| exit(1) | |
| } | |
| // Child process continues here | |
| guard let mediaRemote = MediaRemoteClient() else { | |
| fputs("Failed to load MediaRemote\n", stderr) | |
| exit(1) | |
| } | |
| let app = NSApplication.shared | |
| app.setActivationPolicy(.accessory) | |
| MainActor.assumeIsolated { | |
| let overlay = OverlayWindow(mediaRemote: mediaRemote) | |
| /// Clean shutdown: close window then exit | |
| let signalSources = [SIGTERM, SIGINT].map { signalType -> DispatchSourceSignal in | |
| signal(signalType, SIG_IGN) | |
| let source = DispatchSource.makeSignalSource(signal: signalType, queue: .main) | |
| source.setEventHandler { | |
| overlay.close() | |
| exit(0) | |
| } | |
| source.resume() | |
| return source | |
| } | |
| _ = signalSources | |
| Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in | |
| MainActor.assumeIsolated { overlay.pollMediaRemote() } | |
| } | |
| overlay.pollMediaRemote() | |
| overlay.startDisplayLink() | |
| } | |
| app.run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment