diff --git a/Resources/uk.lproj/Localizable.strings b/Resources/uk.lproj/Localizable.strings index 10b1836..a22df8a 100644 --- a/Resources/uk.lproj/Localizable.strings +++ b/Resources/uk.lproj/Localizable.strings @@ -8,6 +8,7 @@ "Snippets" = "Шаблони"; "Calendar" = "Календар"; "Translate" = "Переклад"; +"Teleprompter" = "Суфлер"; /* Меню-бар */ "Open Panel" = "Відкрити панель"; diff --git a/Scripts/bundle.sh b/Scripts/bundle.sh index 1d7cd79..7bd0823 100755 --- a/Scripts/bundle.sh +++ b/Scripts/bundle.sh @@ -60,6 +60,10 @@ if [ -f "$ROOT/Resources/AppIcon.icns" ]; then cp "$ROOT/Resources/AppIcon.icns" "$APP/Contents/Resources/AppIcon.icns" fi +echo "==> compiling media helper" +clang -fobjc-arc -dynamiclib -o "$APP/Contents/Resources/libvoidmedia.dylib" \ + "$ROOT/Sources/VoidBarMediaHelper/helper.m" + # Таблицы строк кладутся прямо в бандл, а не через ресурсы SwiftPM: бандл здесь # собирается вручную, и .lproj рядом с исполняемым файлом — то, где их ищет сама # macOS. Язык она выбирает потом сама, по списку предпочитаемых у пользователя. diff --git a/Sources/VoidBar/Model/NotchViewModel.swift b/Sources/VoidBar/Model/NotchViewModel.swift index 93e5c20..2315b6f 100644 --- a/Sources/VoidBar/Model/NotchViewModel.swift +++ b/Sources/VoidBar/Model/NotchViewModel.swift @@ -4,7 +4,7 @@ import Combine @MainActor final class NotchViewModel: ObservableObject { enum Tab: String, CaseIterable, Identifiable { - case media, shelf, clipboard, snippets, calendar, translate, notes + case media, shelf, clipboard, snippets, calendar, translate, notes, teleprompter, weather var id: String { rawValue } var symbol: String { @@ -16,6 +16,8 @@ final class NotchViewModel: ObservableObject { case .calendar: return "calendar" case .translate: return "translate" case .notes: return "note.text" + case .teleprompter: return "text.line.first.and.arrowtriangle.forward" + case .weather: return "cloud.sun" } } @@ -28,19 +30,21 @@ final class NotchViewModel: ObservableObject { case .calendar: return localized("Calendar") case .translate: return localized("Translate") case .notes: return localized("Notes") + case .teleprompter: return localized("Teleprompter") + case .weather: return localized("Weather") } } /// Tabs with a field in them. Landing on one hands it the keyboard, so /// that arriving and typing is a single move. - var needsKeyboard: Bool { self == .translate || self == .snippets || self == .notes } + var needsKeyboard: Bool { self == .translate || self == .snippets || self == .notes || self == .teleprompter } /// Which rail the icon sits on. The left one carries the original six /// and is full — a seventh icon would outgrow the height the panel /// body has — so growth continues in a second column on the right, /// which the scratch notes open. static let leftRail: [Tab] = [.media, .shelf, .clipboard, .snippets, .calendar, .translate] - static let rightRail: [Tab] = [.notes] + static let rightRail: [Tab] = [.notes, .teleprompter, .weather] } @Published var isOpen = false @@ -81,6 +85,8 @@ final class NotchViewModel: ObservableObject { let translator: Translator let snippets: SnippetStore let notes: NoteStore + let teleprompter: TeleprompterStore + let weather: WeatherStore private var cancellables = Set() @@ -93,6 +99,8 @@ final class NotchViewModel: ObservableObject { self.translator = Translator() self.snippets = SnippetStore() self.notes = NoteStore() + self.teleprompter = TeleprompterStore() + self.weather = WeatherStore() // The panel header reads through to the stores — counters, the source // name, the equalizer. Nested ObservableObjects do not propagate on @@ -118,6 +126,7 @@ final class NotchViewModel: ObservableObject { shelf.objectWillChange, clipboard.objectWillChange, calendar.objectWillChange, + weather.objectWillChange ] { child .sink { [weak self] _ in @@ -159,6 +168,7 @@ final class NotchViewModel: ObservableObject { // Only picks up where it left off if access was granted earlier; it // never prompts on its own. calendar.start() + weather.start() // Screenshots reach the shelf through here whether they were taken on // this Mac or on a phone: a copy made on the phone arrives in the same @@ -181,8 +191,10 @@ final class NotchViewModel: ObservableObject { media.stop() clipboard.stop() calendar.stop() + weather.stop() // Whatever was typed makes it to disk even when quitting mid-thought. notes.flush() + teleprompter.flush() } func accept(urls: [URL]) -> Bool { diff --git a/Sources/VoidBar/Notch/NotchGeometry.swift b/Sources/VoidBar/Notch/NotchGeometry.swift index 7ad3d35..6ca8c79 100644 --- a/Sources/VoidBar/Notch/NotchGeometry.swift +++ b/Sources/VoidBar/Notch/NotchGeometry.swift @@ -23,12 +23,14 @@ struct NotchGeometry { let left = screen.auxiliaryTopLeftArea, let right = screen.auxiliaryTopRightArea { let width = screen.frame.width - left.width - right.width - return NotchGeometry( - screen: screen, - notchSize: CGSize(width: width, height: screen.safeAreaInsets.top), - notchCenterX: screen.frame.minX + left.width + width / 2, - isPhysical: true - ) + if width > 0 { + return NotchGeometry( + screen: screen, + notchSize: CGSize(width: width, height: screen.safeAreaInsets.top), + notchCenterX: screen.frame.minX + left.width + width / 2, + isPhysical: true + ) + } } // No notch: pretend there is one the size of a typical MacBook cutout so diff --git a/Sources/VoidBar/Services/CalendarStore.swift b/Sources/VoidBar/Services/CalendarStore.swift index 600eb24..3fb1abe 100644 --- a/Sources/VoidBar/Services/CalendarStore.swift +++ b/Sources/VoidBar/Services/CalendarStore.swift @@ -31,9 +31,23 @@ final class CalendarStore: ObservableObject { @Published private(set) var access: Access = .notRequested @Published private(set) var meetings: [Meeting] = [] + @Published private(set) var availableCalendars: [EKCalendar] = [] /// Recomputed on a timer so the countdown in the header stays honest. @Published private(set) var now = Date() + var disabledCalendarIDs: Set { + get { + if let array = UserDefaults.standard.stringArray(forKey: "disabledCalendarIDs") { + return Set(array) + } + return [] + } + set { + UserDefaults.standard.set(Array(newValue), forKey: "disabledCalendarIDs") + reload() + } + } + private let store = EKEventStore() private var timer: Timer? private var observer: Any? @@ -156,11 +170,22 @@ final class CalendarStore: ObservableObject { func reload() { guard access == .granted else { return } + + availableCalendars = store.calendars(for: .event) + let disabled = disabledCalendarIDs + let activeCalendars = availableCalendars.filter { !disabled.contains($0.calendarIdentifier) } + + if activeCalendars.isEmpty && !availableCalendars.isEmpty { + meetings = [] + now = Date() + return + } + let start = Date() let predicate = store.predicateForEvents( withStart: start, end: start.addingTimeInterval(horizon), - calendars: nil + calendars: activeCalendars ) meetings = store.events(matching: predicate) .filter { !$0.isAllDay && $0.status != .canceled } diff --git a/Sources/VoidBar/Services/NowPlayingFeed.swift b/Sources/VoidBar/Services/NowPlayingFeed.swift index 5e88729..91717f6 100644 --- a/Sources/VoidBar/Services/NowPlayingFeed.swift +++ b/Sources/VoidBar/Services/NowPlayingFeed.swift @@ -1,6 +1,7 @@ import AppKit -/// Reads the Now Playing state and turns it into snapshots. +/// Runs the Now Playing helper inside `/usr/bin/perl` and turns its stdout into +/// snapshots. See `Sources/VoidBarMediaHelper/helper.m` for why perl is the host. @MainActor final class NowPlayingFeed { struct Snapshot { @@ -24,24 +25,143 @@ final class NowPlayingFeed { } var onUpdate: ((Snapshot) -> Void)? - /// Raised when the feed cannot run at all, so the caller can fall back. + /// Raised when the helper cannot run at all, so the caller can fall back. var onUnavailable: (() -> Void)? + private var process: Process? + private var input: FileHandle? + private var buffer = Data() + private var failures = 0 + private var stopped = false + + private var helperPath: String? { + Bundle.main.path(forResource: "libvoidmedia", ofType: "dylib") + } + // MARK: - Lifecycle func start() { - // The old media helper has been completely removed for security reasons. - // We immediately fall back to the safe AppleScript bridge. - DispatchQueue.main.async { [weak self] in - self?.onUnavailable?() + stopped = false + launch() + } + + func stop() { + stopped = true + input = nil + process?.terminate() + process = nil + } + + private func launch() { + guard !stopped else { return } + guard let helperPath, FileManager.default.isExecutableFile(atPath: "/usr/bin/perl") else { + onUnavailable?() + return + } + + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/perl") + task.arguments = [ + "-e", + "use DynaLoader; DynaLoader::dl_load_file($ARGV[0], 0x01); while (1) { sleep 3600; }", + helperPath, + ] + + let output = Pipe() + let commands = Pipe() + task.standardOutput = output + task.standardInput = commands + task.standardError = FileHandle.nullDevice + + output.fileHandleForReading.readabilityHandler = { [weak self] handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { return } + Task { @MainActor in self?.consume(chunk) } + } + + task.terminationHandler = { [weak self] _ in + Task { @MainActor in self?.handleTermination() } + } + + do { + try task.run() + } catch { + NSLog("VoidBar: helper failed to launch: \(error.localizedDescription)") + onUnavailable?() + return } + + process = task + input = commands.fileHandleForWriting } - func stop() {} + private func handleTermination() { + guard !stopped else { return } + process = nil + input = nil + failures += 1 + // Three straight crashes means the route is gone — perl removed, or the + // daemon closed to platform binaries too. Let the caller fall back. + guard failures < 3 else { + onUnavailable?() + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in self?.launch() } + } // MARK: - Commands - func refresh() {} - func send(_ command: Command) {} - func seek(to seconds: TimeInterval) {} + func refresh() { write("get") } + func send(_ command: Command) { write("cmd \(command.rawValue)") } + func seek(to seconds: TimeInterval) { write("seek \(Int(seconds))") } + + private func write(_ line: String) { + guard let input, let data = (line + "\n").data(using: .utf8) else { return } + // The helper can die between our check and the write; a broken pipe + // would raise SIGPIPE-flavoured NSException from FileHandle. + do { + try input.write(contentsOf: data) + } catch { + NSLog("VoidBar: helper write failed: \(error.localizedDescription)") + } + } + + // MARK: - Parsing + + private func consume(_ chunk: Data) { + buffer.append(chunk) + while let newline = buffer.firstIndex(of: 0x0A) { + let line = buffer[buffer.startIndex.. 4_000_000 { buffer.removeAll() } + } + + private func handle(line: Data) { + guard let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any] else { return } + if object["error"] != nil { + onUnavailable?() + return + } + failures = 0 + + var snapshot = Snapshot() + snapshot.isPlaying = object["playing"] as? Bool ?? false + snapshot.title = object["title"] as? String ?? "" + snapshot.artist = object["artist"] as? String ?? "" + snapshot.album = object["album"] as? String ?? "" + snapshot.duration = object["duration"] as? Double ?? 0 + snapshot.elapsed = object["elapsed"] as? Double ?? 0 + snapshot.rate = object["rate"] as? Double ?? 0 + if let base64 = object["artwork"] as? String { + snapshot.artwork = Data(base64Encoded: base64) + } + if let pid = object["pid"] as? Int, pid > 0 { + snapshot.source = NSRunningApplication(processIdentifier: pid_t(pid))?.localizedName + } + onUpdate?(snapshot) + } } diff --git a/Sources/VoidBar/Services/WeatherStore.swift b/Sources/VoidBar/Services/WeatherStore.swift new file mode 100644 index 0000000..37f06b5 --- /dev/null +++ b/Sources/VoidBar/Services/WeatherStore.swift @@ -0,0 +1,73 @@ +import Foundation +import Combine + +struct WeatherData: Codable { + let temperature: Double + let condition: Int + let locationName: String? +} + +@MainActor +final class WeatherStore: ObservableObject { + @Published var weather: WeatherData? + @Published var error: String? + + private var timer: Timer? + + func start() { + stop() + // Poll every 30 minutes + timer = Timer.scheduledTimer(withTimeInterval: 1800, repeats: true) { [weak self] _ in + Task { @MainActor in + await self?.fetchWeather() + } + } + Task { + await fetchWeather() + } + } + + func stop() { + timer?.invalidate() + timer = nil + } + + private func fetchWeather() async { + do { + // Get location via IP + guard let locationUrl = URL(string: "https://ipapi.co/json/") else { return } + let (locationData, _) = try await URLSession.shared.data(from: locationUrl) + + struct IPResponse: Decodable { + let latitude: Double + let longitude: Double + let city: String + } + let ipResponse = try JSONDecoder().decode(IPResponse.self, from: locationData) + + // Get weather from Open-Meteo + let weatherUrlString = "https://api.open-meteo.com/v1/forecast?latitude=\(ipResponse.latitude)&longitude=\(ipResponse.longitude)¤t_weather=true" + guard let weatherUrl = URL(string: weatherUrlString) else { return } + + let (weatherJsonData, _) = try await URLSession.shared.data(from: weatherUrl) + + struct MeteoResponse: Decodable { + struct CurrentWeather: Decodable { + let temperature: Double + let weathercode: Int + } + let current_weather: CurrentWeather + } + let meteoResponse = try JSONDecoder().decode(MeteoResponse.self, from: weatherJsonData) + + self.weather = WeatherData( + temperature: meteoResponse.current_weather.temperature, + condition: meteoResponse.current_weather.weathercode, + locationName: ipResponse.city + ) + self.error = nil + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/Sources/VoidBar/UI/CalendarPane.swift b/Sources/VoidBar/UI/CalendarPane.swift index 1d5c5ee..3f206ed 100644 --- a/Sources/VoidBar/UI/CalendarPane.swift +++ b/Sources/VoidBar/UI/CalendarPane.swift @@ -10,10 +10,14 @@ struct CalendarPane: View { case .denied: deniedState case .granted: - if let next = calendar.next { - agenda(next: next) - } else { - emptyState + ZStack(alignment: .topTrailing) { + if let next = calendar.next { + agenda(next: next) + } else { + emptyState + } + + settingsMenu } } } @@ -211,4 +215,36 @@ struct CalendarPane: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } + + private var settingsMenu: some View { + Menu { + ForEach(calendar.availableCalendars, id: \.calendarIdentifier) { cal in + Button { + var disabled = calendar.disabledCalendarIDs + if disabled.contains(cal.calendarIdentifier) { + disabled.remove(cal.calendarIdentifier) + } else { + disabled.insert(cal.calendarIdentifier) + } + calendar.disabledCalendarIDs = disabled + } label: { + HStack { + if !calendar.disabledCalendarIDs.contains(cal.calendarIdentifier) { + Image(systemName: "checkmark") + } + Text(cal.title) + } + } + } + } label: { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 12)) + .foregroundStyle(Theme.secondary) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .frame(width: 24, height: 24) + .padding(.trailing, 0) + .padding(.top, -2) + } } diff --git a/Sources/VoidBar/UI/DynamicIslandView.swift b/Sources/VoidBar/UI/DynamicIslandView.swift new file mode 100644 index 0000000..b45e62d --- /dev/null +++ b/Sources/VoidBar/UI/DynamicIslandView.swift @@ -0,0 +1,105 @@ +import SwiftUI + +struct DynamicIslandView: View { + @ObservedObject var vm: NotchViewModel + + // The width of the actual physical notch + private var notchWidth: CGFloat { vm.geometry.notchSize.width } + // The height of the physical notch + private var notchHeight: CGFloat { vm.geometry.notchSize.height } + + // Animation for equalizer + @State private var phase: CGFloat = 0 + + var body: some View { + // We position the islands exactly outside the physical notch width. + // We are drawing inside a frame that is `size.width + 2*topRadius` wide. + // The center of this frame corresponds to the center of the notch. + + ZStack { + if vm.media.isPlaying { + if vm.geometry.isPhysical { + // Left Wing (Equalizer / Icon) + HStack { + Spacer() // push to right edge of the left wing + EqualizerBars(isAnimating: true) + .padding(.trailing, 10) + } + .frame(width: 44, height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + .offset(x: -notchWidth / 2 - 22 + 8) // Overlap slightly to merge with notch + + // Right Wing (Timer / Source) + HStack { + if vm.media.track != nil { + // Very simple static text for now, ideally an updating timer + Text(formatTime(vm.media.position)) + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundColor(Color.white) + .padding(.leading, 10) + } + Spacer() + } + .frame(width: 44, height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + .offset(x: notchWidth / 2 + 22 - 8) + } else { + // Unified Pill for non-notched screens + HStack(spacing: 8) { + EqualizerBars(isAnimating: true) + + if vm.media.track != nil { + Text(formatTime(vm.media.position)) + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundColor(Color.white) + } + } + .padding(.horizontal, 12) + .frame(height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + // When unified, it just floats in the center + } + } else if let weather = vm.weather.weather { + if vm.geometry.isPhysical { + // Right Wing (Weather) + HStack { + Text(String(format: "%.0f°", weather.temperature)) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Color.white) + .padding(.leading, 12) + Spacer() + } + .frame(width: 44, height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + .offset(x: notchWidth / 2 + 22 - 8) + } else { + // Unified Pill + HStack(spacing: 4) { + Image(systemName: "cloud.sun.fill") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.white) + Text(String(format: "%.0f°", weather.temperature)) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Color.white) + } + .padding(.horizontal, 12) + .frame(height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + } + } + } + .frame(width: notchWidth, height: notchHeight) // Center aligns with notch + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: vm.media.isPlaying) + } + + private func formatTime(_ seconds: Double) -> String { + let mins = Int(seconds) / 60 + let secs = Int(seconds) % 60 + return String(format: "%d:%02d", mins, secs) + } +} diff --git a/Sources/VoidBar/UI/NotchContentView.swift b/Sources/VoidBar/UI/NotchContentView.swift index 5114dbf..f525f1f 100644 --- a/Sources/VoidBar/UI/NotchContentView.swift +++ b/Sources/VoidBar/UI/NotchContentView.swift @@ -16,6 +16,7 @@ struct NotchContentView: View { bottomRadius: isOpen ? Theme.openBottomRadius : Theme.collapsedBottomRadius ) .fill(Color.black) + .opacity(!isOpen && !vm.geometry.isPhysical ? 0 : 1) .frame(width: size.width + 2 * topRadius, height: size.height) .shadow(color: .black.opacity(isOpen ? 0.5 : 0), radius: 18, y: 8) @@ -28,6 +29,11 @@ struct NotchContentView: View { } .frame(width: size.width, height: size.height, alignment: .top) .clipped() + + if !isOpen { + DynamicIslandView(vm: vm) + .transition(.opacity) + } } .frame(width: size.width + 2 * topRadius, height: size.height, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) @@ -96,6 +102,10 @@ struct NotchContentView: View { EmptyView() case .notes: NotesCounter(notes: vm.notes) + case .teleprompter: + EmptyView() + case .weather: + EmptyView() } } @@ -157,6 +167,10 @@ struct NotchContentView: View { TranslatePane(translator: vm.translator, wantsKeyboard: $vm.wantsKeyboard) case .notes: NotesPane(notes: vm.notes, wantsKeyboard: $vm.wantsKeyboard) + case .teleprompter: + TeleprompterPane(store: vm.teleprompter) + case .weather: + WeatherPane(weatherStore: vm.weather) } } } diff --git a/Sources/VoidBar/UI/TeleprompterPane.swift b/Sources/VoidBar/UI/TeleprompterPane.swift new file mode 100644 index 0000000..58b5655 --- /dev/null +++ b/Sources/VoidBar/UI/TeleprompterPane.swift @@ -0,0 +1,102 @@ +import SwiftUI + +struct TeleprompterPane: View { + @ObservedObject var store: TeleprompterStore + @FocusState private var isFocused: Bool + + // For auto-scrolling + @State private var offset: CGFloat = 0 + @State private var timer: Timer? + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text(localized("Teleprompter")) + .font(.headline) + .foregroundColor(Theme.secondary) + + Spacer() + + // Controls + Slider(value: $store.speed, in: 0.2...3.0, step: 0.1) + .frame(width: 80) + .tint(Color.white) + + Button { + togglePlay() + } label: { + Image(systemName: store.isPlaying ? "pause.fill" : "play.fill") + .foregroundColor(store.isPlaying ? Color.white : Theme.secondary) + .padding(6) + .background(Theme.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + + // Content + ZStack(alignment: .top) { + if store.isPlaying { + // Playing mode: Auto-scrolling text + GeometryReader { geo in + ScrollView(.vertical, showsIndicators: false) { + Text(store.text) + .font(.system(size: 24, weight: .medium, design: .default)) + .foregroundColor(Color.white) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.vertical, geo.size.height / 2) // Start from middle + .offset(y: -offset) + .frame(maxWidth: .infinity) + } + .disabled(true) // Disable manual scroll while playing + } + } else { + // Edit mode: Standard TextEditor + TextEditor(text: $store.text) + .font(.system(size: 16)) + .foregroundColor(Color.white) + .scrollContentBackground(.hidden) + .background(Color.clear) + .padding(.horizontal, 12) + .focused($isFocused) + } + } + .frame(maxHeight: .infinity) + } + .onDisappear { + stopTimer() + } + } + + private func togglePlay() { + store.isPlaying.toggle() + if store.isPlaying { + isFocused = false + offset = 0 // Reset scroll + startTimer() + } else { + stopTimer() + } + } + + private func startTimer() { + timer?.invalidate() + // 60 fps + timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { _ in + MainActor.assumeIsolated { + offset += store.speed + } + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + store.isPlaying = false + } +} diff --git a/Sources/VoidBar/UI/WeatherPane.swift b/Sources/VoidBar/UI/WeatherPane.swift new file mode 100644 index 0000000..2e57127 --- /dev/null +++ b/Sources/VoidBar/UI/WeatherPane.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct WeatherPane: View { + @ObservedObject var weatherStore: WeatherStore + + var body: some View { + VStack(spacing: 8) { + if let error = weatherStore.error { + Text(error) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.red) + .multilineTextAlignment(.center) + } else if let weather = weatherStore.weather { + Image(systemName: icon(for: weather.condition)) + .font(.system(size: 36, weight: .light)) + .foregroundStyle(.white) + + Text(String(format: "%.1f°", weather.temperature)) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(.white) + + if let location = weather.locationName { + Text(location) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Theme.secondary) + } + } else { + ProgressView() + .controlSize(.small) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 16) + } + + private func icon(for code: Int) -> String { + switch code { + case 0: return "sun.max.fill" + case 1...3: return "cloud.sun.fill" + case 45, 48: return "cloud.fog.fill" + case 51...67: return "cloud.drizzle.fill" + case 71...77: return "cloud.snow.fill" + case 80...82: return "cloud.heavyrain.fill" + case 95...99: return "cloud.bolt.rain.fill" + default: return "cloud.fill" + } + } +} diff --git a/Sources/VoidBarMediaHelper/helper.m b/Sources/VoidBarMediaHelper/helper.m new file mode 100644 index 0000000..89e3317 --- /dev/null +++ b/Sources/VoidBarMediaHelper/helper.m @@ -0,0 +1,151 @@ +// Now Playing feed, loaded into /usr/bin/perl. +// +// Since macOS 15.4 the mediaremoted daemon answers only clients it trusts, so +// an ordinary app gets an empty dictionary no matter what it asks. Claiming the +// `com.apple.mediaremote.external-access` entitlement does not help either: it +// is restricted, and a process that claims it without Apple's authorization is +// killed at launch. +// +// /usr/bin/perl, however, is a platform binary (Platform identifier=16) that +// the daemon does trust, and it is signed without library validation — so it +// can load this dylib. Running the MediaRemote calls from inside that process +// yields the full record: title, artist, album, duration, position, artwork. +// +// The helper prints one JSON object per line on stdout and takes commands on +// stdin. It exits as soon as stdin closes, so it can never outlive VoidBar. + +#import +#import + +typedef void (*MRGetInfoFn)(dispatch_queue_t, void (^)(CFDictionaryRef)); +typedef void (*MRGetBoolFn)(dispatch_queue_t, void (^)(Boolean)); +typedef void (*MRRegisterFn)(dispatch_queue_t); +typedef Boolean (*MRSendCommandFn)(int, CFDictionaryRef); +typedef void (*MRSetElapsedFn)(double); +typedef void (*MRGetPIDFn)(dispatch_queue_t, void (^)(int)); + +static MRGetInfoFn sGetInfo; +static MRGetBoolFn sGetIsPlaying; +static MRSendCommandFn sSendCommand; +static MRSetElapsedFn sSetElapsed; +static MRGetPIDFn sGetPID; +static int sOwnerPID; +static dispatch_queue_t sQueue; +static NSString *sArtworkID; + +static NSString *const kMediaRemotePath = + @"/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote"; + +static void emit(NSDictionary *payload) { + NSData *json = [NSJSONSerialization dataWithJSONObject:payload options:0 error:NULL]; + if (!json) return; + fwrite(json.bytes, 1, json.length, stdout); + fputc('\n', stdout); + fflush(stdout); +} + +/// Reads the current record and prints it. Artwork is only included when the +/// track changed — it is the bulk of the payload and never changes mid-track. +static void publish(void) { + if (!sGetInfo || !sGetIsPlaying) return; + // Cached rather than nested a call deeper: it only labels the source. + if (sGetPID) sGetPID(sQueue, ^(int pid) { sOwnerPID = pid; }); + sGetIsPlaying(sQueue, ^(Boolean playing) { + sGetInfo(sQueue, ^(CFDictionaryRef raw) { + NSDictionary *info = (__bridge NSDictionary *)raw; + NSString *title = info[@"kMRMediaRemoteNowPlayingInfoTitle"] ?: @""; + + NSMutableDictionary *out = [NSMutableDictionary dictionary]; + out[@"playing"] = @(playing ? YES : NO); + out[@"title"] = title; + out[@"artist"] = info[@"kMRMediaRemoteNowPlayingInfoArtist"] ?: @""; + out[@"album"] = info[@"kMRMediaRemoteNowPlayingInfoAlbum"] ?: @""; + out[@"duration"] = info[@"kMRMediaRemoteNowPlayingInfoDuration"] ?: @0; + out[@"elapsed"] = info[@"kMRMediaRemoteNowPlayingInfoElapsedTime"] ?: @0; + out[@"rate"] = info[@"kMRMediaRemoteNowPlayingInfoPlaybackRate"] ?: @0; + out[@"pid"] = @(sOwnerPID); + + NSString *artworkID = info[@"kMRMediaRemoteNowPlayingInfoArtworkIdentifier"] ?: title; + NSData *artwork = info[@"kMRMediaRemoteNowPlayingInfoArtworkData"]; + if (artwork.length > 0 && ![artworkID isEqualToString:sArtworkID]) { + out[@"artwork"] = [artwork base64EncodedStringWithOptions:0]; + sArtworkID = artworkID; + } + if (title.length == 0) sArtworkID = nil; + + emit(out); + }); + }); +} + +static void handleCommand(NSString *line) { + if ([line isEqualToString:@"get"]) { + publish(); + } else if ([line hasPrefix:@"cmd "]) { + if (sSendCommand) sSendCommand([line substringFromIndex:4].intValue, NULL); + publish(); + } else if ([line hasPrefix:@"seek "]) { + if (sSetElapsed) sSetElapsed([line substringFromIndex:5].doubleValue); + publish(); + } +} + +static void startFeed(void) { + [NSThread detachNewThreadWithBlock:^{ + sQueue = dispatch_queue_create("dev.xand0.voidbar.mediaremote", DISPATCH_QUEUE_SERIAL); + + void *handle = dlopen(kMediaRemotePath.UTF8String, RTLD_NOW); + if (!handle) { + emit(@{@"error": @"mediaremote-unavailable"}); + return; + } + sGetInfo = (MRGetInfoFn)dlsym(handle, "MRMediaRemoteGetNowPlayingInfo"); + sGetIsPlaying = (MRGetBoolFn)dlsym(handle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying"); + sSendCommand = (MRSendCommandFn)dlsym(handle, "MRMediaRemoteSendCommand"); + sSetElapsed = (MRSetElapsedFn)dlsym(handle, "MRMediaRemoteSetElapsedTime"); + sGetPID = (MRGetPIDFn)dlsym(handle, "MRMediaRemoteGetNowPlayingApplicationPID"); + + MRRegisterFn registerNotifications = + (MRRegisterFn)dlsym(handle, "MRMediaRemoteRegisterForNowPlayingNotifications"); + if (registerNotifications) registerNotifications(sQueue); + + NSArray *names = @[ + @"kMRMediaRemoteNowPlayingInfoDidChangeNotification", + @"kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification", + @"kMRMediaRemoteNowPlayingApplicationDidChangeNotification", + ]; + for (NSString *name in names) { + [NSNotificationCenter.defaultCenter addObserverForName:name + object:nil + queue:nil + usingBlock:^(NSNotification *note) { + publish(); + }]; + } + + publish(); + [NSRunLoop.currentRunLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; + [NSRunLoop.currentRunLoop run]; + }]; +} + +static void startCommandReader(void) { + [NSThread detachNewThreadWithBlock:^{ + char buffer[512]; + while (fgets(buffer, sizeof buffer, stdin)) { + @autoreleasepool { + NSString *line = [@(buffer) stringByTrimmingCharactersInSet: + NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (line.length) handleCommand(line); + } + } + // VoidBar closed the pipe or went away. + exit(0); + }]; +} + +__attribute__((constructor)) +static void voidbar_helper_init(void) { + startFeed(); + startCommandReader(); +}