Die Einrichtung beim ersten Start erklärt jede der vier Sachen einzeln und fragt jede einzeln an. Vier Systemdialoge hintereinander wären der übliche Weg und der falsche: wer nicht weiß wofür, klickt viermal „Nicht erlauben", und danach fragt macOS nie wieder. Sie kommt beim ersten Start immer, auch wenn zufällig schon alles erlaubt ist — sie erklärt auch den Helfer und den Autostart. Beim Autostart ist `requiresApproval` der Zustand, der weh tut. Wer das Anmeldeobjekt in den Systemeinstellungen abgeschaltet hat, kann in Onyx auf den Schalter drücken, so oft er will. Als „aus" angezeigt führt das in eine Schleife, also steht dort jetzt, wo es weitergeht. Der Mixer verliert den Schalter je Programm. Zehn Mini-Schalter untereinander sind das, was den Eindruck macht; am Regler zu ziehen ist ohnehin die Entscheidung, dieses Programm zu regeln, und der große Ausschalter stellt alles zurück. Der Regler ist selbst gezeichnet — der Systemregler bringt sein eigenes Erscheinungsbild mit und kann nicht zeigen, worauf es hier ankommt: den Pegel und die Grenze bei 100 %. Der Pegel wird im selben Durchlauf gemessen, in dem verstärkt wird, und über `Atomic` weitergereicht. Eine gewöhnliche Eigenschaft wäre ein Wettlauf, eine Sperre im Audiothread der klassische Weg zu Aussetzern. Dabei fällt das `Date()` aus dem Echtzeitpfad, das dort nie hingehört hat. Zwei Funde beim Bauen des Release-Wegs, beide hätten erst nach dem Hochladen zur Ablehnung geführt: der Helfer wurde ohne sicheren Zeitstempel signiert, und das Adapter-Framework ad-hoc. Beides gilt jetzt nur noch im Debug. Scripts/release.sh prüft Zertifikat und Notarisierungsprofil vorweg, statt nach zehn Minuten Bauen zu scheitern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
7.0 KiB
Swift
207 lines
7.0 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
import CalendarProvider
|
|
import WeatherProvider
|
|
import AudioProvider
|
|
|
|
/// Der erste Start.
|
|
///
|
|
/// Onyx braucht vier Dinge vom System, und jedes für etwas anderes. Sie alle
|
|
/// beim Start hintereinander abzufragen wäre der übliche Weg und der falsche:
|
|
/// wer vier Dialoge sieht, ohne zu wissen wofür, klickt viermal „Nicht
|
|
/// erlauben" — und macOS fragt danach nie wieder. Deshalb steht hier zu jedem
|
|
/// Punkt, wofür er gut ist, und jeder wird einzeln und auf Klick angefragt.
|
|
///
|
|
/// Nichts davon ist Pflicht. Ohne Kalenderzugriff fehlt ein Widget, nicht die
|
|
/// App.
|
|
struct OnboardingView: View {
|
|
|
|
let calendarModel: CalendarModel
|
|
let weatherModel: WeatherModel
|
|
let mixer: AudioMixer
|
|
let fanControl: FanControl
|
|
let launchAtLogin: LaunchAtLogin
|
|
let onFinish: () -> Void
|
|
|
|
@State private var calendarGranted = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
header
|
|
|
|
Divider()
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
OnboardingRow(
|
|
symbol: "calendar",
|
|
title: "onboarding.calendar",
|
|
detail: "onboarding.calendar.why",
|
|
state: calendarState) {
|
|
calendarGranted = await calendarModel.requestAccessNow()
|
|
} openSettings: {
|
|
open("Privacy_Calendars")
|
|
}
|
|
|
|
OnboardingRow(
|
|
symbol: "location",
|
|
title: "onboarding.location",
|
|
detail: "onboarding.location.why",
|
|
state: locationState) {
|
|
weatherModel.requestLocationAuthorization()
|
|
} openSettings: {
|
|
open("Privacy_LocationServices")
|
|
}
|
|
|
|
OnboardingRow(
|
|
symbol: "slider.horizontal.3",
|
|
title: "onboarding.audio",
|
|
detail: "onboarding.audio.why",
|
|
state: mixer.isEnabled ? .granted : .undetermined) {
|
|
mixer.enable()
|
|
} openSettings: {
|
|
open("Privacy_Microphone")
|
|
}
|
|
|
|
OnboardingRow(
|
|
symbol: "fan",
|
|
title: "onboarding.helper",
|
|
detail: "onboarding.helper.why",
|
|
state: helperState) {
|
|
fanControl.install()
|
|
} openSettings: {
|
|
fanControl.openApprovalSettings()
|
|
}
|
|
}
|
|
.padding(.horizontal, 22)
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
Divider()
|
|
footer
|
|
}
|
|
.frame(width: 520, height: 520)
|
|
.task {
|
|
calendarGranted = calendarModel.hasAccess
|
|
fanControl.refreshInstallState()
|
|
launchAtLogin.refresh()
|
|
}
|
|
}
|
|
|
|
// MARK: - Kopf und Fuß
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 14) {
|
|
Image(nsImage: NSApp.applicationIconImage)
|
|
.resizable().frame(width: 52, height: 52)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("onboarding.title").font(.title2).fontWeight(.semibold)
|
|
Text("onboarding.subtitle")
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(22)
|
|
}
|
|
|
|
private var footer: some View {
|
|
HStack {
|
|
Toggle(isOn: Binding(
|
|
get: { launchAtLogin.isEnabled },
|
|
set: { _ = launchAtLogin.set($0) })) {
|
|
Text("onboarding.launchAtLogin")
|
|
}
|
|
.toggleStyle(.checkbox)
|
|
.disabled(launchAtLogin.needsSystemSettings)
|
|
|
|
if launchAtLogin.needsSystemSettings {
|
|
Button("onboarding.openSettings") { launchAtLogin.openSystemSettings() }
|
|
.buttonStyle(.link)
|
|
}
|
|
|
|
Spacer()
|
|
Button("onboarding.done", action: onFinish)
|
|
.keyboardShortcut(.defaultAction)
|
|
}
|
|
.padding(22)
|
|
}
|
|
|
|
// MARK: - Zustände
|
|
|
|
private var calendarState: OnboardingRow.State {
|
|
if calendarGranted { return .granted }
|
|
return calendarModel.authorization == .denied ? .denied : .undetermined
|
|
}
|
|
|
|
private var locationState: OnboardingRow.State {
|
|
switch weatherModel.locationAuthorization {
|
|
case .granted: .granted
|
|
case .denied: .denied
|
|
case .undetermined: .undetermined
|
|
}
|
|
}
|
|
|
|
private var helperState: OnboardingRow.State {
|
|
switch fanControl.installState {
|
|
case .installed: .granted
|
|
// Nicht `.denied`: hier hilft tatsächlich der Gang in die
|
|
// Systemeinstellungen, und der Knopf soll das auch sagen.
|
|
case .requiresApproval: .denied
|
|
case .notInstalled, .failed: .undetermined
|
|
}
|
|
}
|
|
|
|
private func open(_ pane: String) {
|
|
guard let url = URL(
|
|
string: "x-apple.systempreferences:com.apple.preference.security?\(pane)")
|
|
else { return }
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
|
|
/// Ein Punkt im Onboarding: wofür, wie es steht, und was von hier weiterführt.
|
|
private struct OnboardingRow: View {
|
|
enum State { case granted, denied, undetermined }
|
|
|
|
let symbol: String
|
|
let title: LocalizedStringKey
|
|
let detail: LocalizedStringKey
|
|
let state: State
|
|
let request: () async -> Void
|
|
let openSettings: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 14) {
|
|
Image(systemName: symbol)
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(state == .granted ? .green : .secondary)
|
|
.frame(width: 22, height: 22)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title).fontWeight(.medium)
|
|
Text(detail)
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
switch state {
|
|
case .granted:
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
.font(.system(size: 16))
|
|
case .undetermined:
|
|
Button("onboarding.allow") { Task { await request() } }
|
|
case .denied:
|
|
// Nach einer Ablehnung fragt macOS nie wieder — ein
|
|
// Erlauben-Knopf wäre hier eine Lüge.
|
|
Button("onboarding.openSettings", action: openSettings)
|
|
}
|
|
}
|
|
.padding(.vertical, 12)
|
|
.overlay(alignment: .bottom) { Divider() }
|
|
}
|
|
}
|