Drag files in Finder, hold a modifier, and drop on a wheel of outputs. Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation, the bundled CLI tools, and optional ffmpeg/LibreOffice. Includes a --self-test suite (41 checks) and a --convert CLI mode so the conversion matrix can be verified without the UI.
89 lines
3.4 KiB
Swift
89 lines
3.4 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
/// Small, non-modal confirmations ("Path copied", "3 files converted").
|
|
/// Nothing here blocks the user's flow, which is the whole point of the app.
|
|
enum Notifier {
|
|
private static var toastWindow: NSWindow?
|
|
private static var dismissWorkItem: DispatchWorkItem?
|
|
|
|
@MainActor
|
|
static func flash(title: String, body: String? = nil, symbol: String = "checkmark.circle.fill") {
|
|
guard Settings.shared.showProgressWindow else { return }
|
|
show(title: title, body: body, symbol: symbol, tone: .success, duration: 2.0)
|
|
}
|
|
|
|
@MainActor
|
|
static func error(_ title: String, _ message: String) {
|
|
show(title: title, body: message, symbol: "exclamationmark.triangle.fill", tone: .failure, duration: 6.0)
|
|
}
|
|
|
|
private enum Tone { case success, failure }
|
|
|
|
@MainActor
|
|
private static func show(title: String, body: String?, symbol: String, tone: Tone, duration: TimeInterval) {
|
|
dismissWorkItem?.cancel()
|
|
toastWindow?.close()
|
|
|
|
let accent: Color = tone == .success ? .green : .orange
|
|
let view = HStack(spacing: 10) {
|
|
Image(systemName: symbol)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(accent)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.lineLimit(1)
|
|
if let body, !body.isEmpty {
|
|
Text(body)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
.truncationMode(.middle)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.frame(minWidth: 220, maxWidth: 420, alignment: .leading)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(Color.primary.opacity(0.08))
|
|
)
|
|
|
|
let controller = NSHostingController(rootView: view)
|
|
let window = NSWindow(contentViewController: controller)
|
|
window.styleMask = [.borderless]
|
|
window.isOpaque = false
|
|
window.backgroundColor = .clear
|
|
window.hasShadow = true
|
|
window.level = .statusBar
|
|
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
|
window.ignoresMouseEvents = true
|
|
window.appearance = NSApp.effectiveAppearance
|
|
|
|
let size = controller.view.fittingSize
|
|
let screen = NSScreen.main ?? NSScreen.screens.first
|
|
if let frame = screen?.visibleFrame {
|
|
let origin = CGPoint(x: frame.midX - size.width / 2, y: frame.minY + 96)
|
|
window.setFrame(CGRect(origin: origin, size: size), display: true)
|
|
}
|
|
window.orderFrontRegardless()
|
|
toastWindow = window
|
|
|
|
let work = DispatchWorkItem { [weak window] in
|
|
NSAnimationContext.runAnimationGroup { context in
|
|
context.duration = 0.25
|
|
window?.animator().alphaValue = 0
|
|
} completionHandler: {
|
|
window?.close()
|
|
}
|
|
if toastWindow === window { toastWindow = nil }
|
|
}
|
|
dismissWorkItem = work
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: work)
|
|
}
|
|
}
|