Skip to content
1 change: 1 addition & 0 deletions Resources/uk.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"Snippets" = "Шаблони";
"Calendar" = "Календар";
"Translate" = "Переклад";
"Teleprompter" = "Суфлер";

/* Меню-бар */
"Open Panel" = "Відкрити панель";
Expand Down
4 changes: 4 additions & 0 deletions Scripts/bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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. Язык она выбирает потом сама, по списку предпочитаемых у пользователя.
Expand Down
18 changes: 15 additions & 3 deletions Sources/VoidBar/Model/NotchViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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<AnyCancellable>()

Expand All @@ -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
Expand All @@ -118,6 +126,7 @@ final class NotchViewModel: ObservableObject {
shelf.objectWillChange,
clipboard.objectWillChange,
calendar.objectWillChange,
weather.objectWillChange
] {
child
.sink { [weak self] _ in
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
14 changes: 8 additions & 6 deletions Sources/VoidBar/Notch/NotchGeometry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion Sources/VoidBar/Services/CalendarStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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?
Expand Down Expand Up @@ -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 }
Expand Down
140 changes: 130 additions & 10 deletions Sources/VoidBar/Services/NowPlayingFeed.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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..<newline]
buffer = buffer[buffer.index(after: newline)...]
guard !line.isEmpty else { continue }
handle(line: Data(line))
}
// Guard against a runaway line if the helper ever misbehaves.
if buffer.count > 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)
}
}
Loading
Loading