Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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, monitor
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 .monitor: return "cpu"
}
}

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 .monitor: return localized("Monitor")
}
}

/// 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, .monitor]
}

@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 monitor: SystemMonitorStore

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.monitor = SystemMonitorStore()

// 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,
monitor.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()
monitor.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()
monitor.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