Compare commits
5
Commits
3da673662f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3ad9c49d6 | ||
|
|
91b83928ca | ||
|
|
a3399d3a96 | ||
|
|
fd29df51cf | ||
|
|
e3b215a1dd |
@@ -7,6 +7,7 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
00FC2E6D214B866B05D66F0F /* LockProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 3054FDAC34BBC6E684BCFE22 /* LockProvider */; };
|
||||||
033116546F93AE6C61A0D9D7 /* NotchAccessories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FDB63D9352C7D2F8406B1DF /* NotchAccessories.swift */; };
|
033116546F93AE6C61A0D9D7 /* NotchAccessories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FDB63D9352C7D2F8406B1DF /* NotchAccessories.swift */; };
|
||||||
08B6F17F5D3B33A8B0F06626 /* PowerActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EDDADF00C8A57744A63191 /* PowerActivity.swift */; };
|
08B6F17F5D3B33A8B0F06626 /* PowerActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EDDADF00C8A57744A63191 /* PowerActivity.swift */; };
|
||||||
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; };
|
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; };
|
||||||
@@ -44,10 +45,12 @@
|
|||||||
C1F651D2BA69358344927F26 /* MediaProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 349226D70F51196CCD1C94F8 /* MediaProvider */; };
|
C1F651D2BA69358344927F26 /* MediaProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 349226D70F51196CCD1C94F8 /* MediaProvider */; };
|
||||||
C390C5B65769A007A49C3A80 /* MetricsProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 6CACA1C2A58DEFDE46F71499 /* MetricsProvider */; };
|
C390C5B65769A007A49C3A80 /* MetricsProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 6CACA1C2A58DEFDE46F71499 /* MetricsProvider */; };
|
||||||
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A5510538846C8427AEB831 /* OnboardingView.swift */; };
|
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A5510538846C8427AEB831 /* OnboardingView.swift */; };
|
||||||
|
CA8D4A7AD49FFA5E3055F3FA /* FaceProvider in Frameworks */ = {isa = PBXBuildFile; productRef = B4ABEE07102A87719470BBFF /* FaceProvider */; };
|
||||||
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3FDFD387188D36AF8287DE /* FanControl.swift */; };
|
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3FDFD387188D36AF8287DE /* FanControl.swift */; };
|
||||||
CD501EBAE39807F5D354C5B2 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; };
|
CD501EBAE39807F5D354C5B2 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; };
|
||||||
DD41F48611EAFC1DC5B28FD4 /* GalleryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C758A5ABC2F748BE7306038 /* GalleryView.swift */; };
|
DD41F48611EAFC1DC5B28FD4 /* GalleryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C758A5ABC2F748BE7306038 /* GalleryView.swift */; };
|
||||||
DD98BEF77E614424C4A37291 /* GalleryWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F63DBABD5B2905865FD4FA2 /* GalleryWindowController.swift */; };
|
DD98BEF77E614424C4A37291 /* GalleryWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F63DBABD5B2905865FD4FA2 /* GalleryWindowController.swift */; };
|
||||||
|
E0986A76EB4F84978D9524EE /* LockSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4EEFE36C277DF75847BCB0 /* LockSettingsView.swift */; };
|
||||||
E7A8CABF31F3CC88C7CB0176 /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4472730A92A48657BDCEB9 /* SettingsSection.swift */; };
|
E7A8CABF31F3CC88C7CB0176 /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4472730A92A48657BDCEB9 /* SettingsSection.swift */; };
|
||||||
EA4D6312E8A96E177F10797F /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; };
|
EA4D6312E8A96E177F10797F /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; };
|
||||||
EF4351407AC9DCDDE225C96F /* NotesProvider in Frameworks */ = {isa = PBXBuildFile; productRef = F8AC40480E05171EB8091269 /* NotesProvider */; };
|
EF4351407AC9DCDDE225C96F /* NotesProvider in Frameworks */ = {isa = PBXBuildFile; productRef = F8AC40480E05171EB8091269 /* NotesProvider */; };
|
||||||
@@ -95,6 +98,7 @@
|
|||||||
B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
||||||
B8214C5599D1857E832447EC /* bolt-light.svg */ = {isa = PBXFileReference; path = "bolt-light.svg"; sourceTree = "<group>"; };
|
B8214C5599D1857E832447EC /* bolt-light.svg */ = {isa = PBXFileReference; path = "bolt-light.svg"; sourceTree = "<group>"; };
|
||||||
B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||||
|
DA4EEFE36C277DF75847BCB0 /* LockSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockSettingsView.swift; sourceTree = "<group>"; };
|
||||||
DF4472730A92A48657BDCEB9 /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = "<group>"; };
|
DF4472730A92A48657BDCEB9 /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = "<group>"; };
|
||||||
E2585137DB7F67F91768E0EF /* WindowPlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowPlacement.swift; sourceTree = "<group>"; };
|
E2585137DB7F67F91768E0EF /* WindowPlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowPlacement.swift; sourceTree = "<group>"; };
|
||||||
F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = "<group>"; };
|
F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = "<group>"; };
|
||||||
@@ -123,6 +127,8 @@
|
|||||||
534B041763B7D4B03DFF53D4 /* ActivityProvider in Frameworks */,
|
534B041763B7D4B03DFF53D4 /* ActivityProvider in Frameworks */,
|
||||||
39B06041FD90361FA72CB386 /* ClipboardProvider in Frameworks */,
|
39B06041FD90361FA72CB386 /* ClipboardProvider in Frameworks */,
|
||||||
EF4351407AC9DCDDE225C96F /* NotesProvider in Frameworks */,
|
EF4351407AC9DCDDE225C96F /* NotesProvider in Frameworks */,
|
||||||
|
CA8D4A7AD49FFA5E3055F3FA /* FaceProvider in Frameworks */,
|
||||||
|
00FC2E6D214B866B05D66F0F /* LockProvider in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -199,6 +205,7 @@
|
|||||||
50A018673EE6BD9C50F1EFA0 /* Info.plist */,
|
50A018673EE6BD9C50F1EFA0 /* Info.plist */,
|
||||||
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */,
|
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */,
|
||||||
508DECB5C05264E6130C373E /* Localizable.xcstrings */,
|
508DECB5C05264E6130C373E /* Localizable.xcstrings */,
|
||||||
|
DA4EEFE36C277DF75847BCB0 /* LockSettingsView.swift */,
|
||||||
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */,
|
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */,
|
||||||
6115888EE7738877C9304138 /* MixerMenuBarModule.swift */,
|
6115888EE7738877C9304138 /* MixerMenuBarModule.swift */,
|
||||||
0FDB63D9352C7D2F8406B1DF /* NotchAccessories.swift */,
|
0FDB63D9352C7D2F8406B1DF /* NotchAccessories.swift */,
|
||||||
@@ -253,6 +260,8 @@
|
|||||||
B9524DE253CBAF4344096790 /* ActivityProvider */,
|
B9524DE253CBAF4344096790 /* ActivityProvider */,
|
||||||
913CFE7A8E299E11C2BF23CF /* ClipboardProvider */,
|
913CFE7A8E299E11C2BF23CF /* ClipboardProvider */,
|
||||||
F8AC40480E05171EB8091269 /* NotesProvider */,
|
F8AC40480E05171EB8091269 /* NotesProvider */,
|
||||||
|
B4ABEE07102A87719470BBFF /* FaceProvider */,
|
||||||
|
3054FDAC34BBC6E684BCFE22 /* LockProvider */,
|
||||||
);
|
);
|
||||||
productName = Onyx;
|
productName = Onyx;
|
||||||
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
||||||
@@ -392,6 +401,7 @@
|
|||||||
DD41F48611EAFC1DC5B28FD4 /* GalleryView.swift in Sources */,
|
DD41F48611EAFC1DC5B28FD4 /* GalleryView.swift in Sources */,
|
||||||
DD98BEF77E614424C4A37291 /* GalleryWindowController.swift in Sources */,
|
DD98BEF77E614424C4A37291 /* GalleryWindowController.swift in Sources */,
|
||||||
B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */,
|
B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */,
|
||||||
|
E0986A76EB4F84978D9524EE /* LockSettingsView.swift in Sources */,
|
||||||
8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */,
|
8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */,
|
||||||
033116546F93AE6C61A0D9D7 /* NotchAccessories.swift in Sources */,
|
033116546F93AE6C61A0D9D7 /* NotchAccessories.swift in Sources */,
|
||||||
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */,
|
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */,
|
||||||
@@ -671,6 +681,10 @@
|
|||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
3054FDAC34BBC6E684BCFE22 /* LockProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = LockProvider;
|
||||||
|
};
|
||||||
349226D70F51196CCD1C94F8 /* MediaProvider */ = {
|
349226D70F51196CCD1C94F8 /* MediaProvider */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = MediaProvider;
|
productName = MediaProvider;
|
||||||
@@ -707,6 +721,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxMenuBar;
|
productName = OnyxMenuBar;
|
||||||
};
|
};
|
||||||
|
B4ABEE07102A87719470BBFF /* FaceProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = FaceProvider;
|
||||||
|
};
|
||||||
B9524DE253CBAF4344096790 /* ActivityProvider */ = {
|
B9524DE253CBAF4344096790 /* ActivityProvider */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = ActivityProvider;
|
productName = ActivityProvider;
|
||||||
|
|||||||
+66
-9
@@ -23,6 +23,8 @@ final class AppModel {
|
|||||||
var onShowsIdleIslandChanged: ((Bool) -> Void)?
|
var onShowsIdleIslandChanged: ((Bool) -> Void)?
|
||||||
var onNotchStyleChanged: ((NotchStyle) -> Void)?
|
var onNotchStyleChanged: ((NotchStyle) -> Void)?
|
||||||
var onNotchWidthsChanged: (([CGDirectDisplayID: CGFloat]) -> Void)?
|
var onNotchWidthsChanged: (([CGDirectDisplayID: CGFloat]) -> Void)?
|
||||||
|
var onFaceUnlockChanged: ((Bool) -> Void)?
|
||||||
|
var onSuppressOSDChanged: ((Bool) -> Void)?
|
||||||
|
|
||||||
var layout: [WidgetPlacement] {
|
var layout: [WidgetPlacement] {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -63,22 +65,66 @@ final class AppModel {
|
|||||||
onNotchWidthsChanged?(notchWidths)
|
onNotchWidthsChanged?(notchWidths)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Was links neben der Notch steht.
|
/// Ob Apples eigene Einblendung unterdrückt wird.
|
||||||
var bandLeading: BandAccessory {
|
///
|
||||||
|
/// Standardmäßig aus: es verschwindet **jede** Einblendung, auch die für
|
||||||
|
/// Helligkeit und Feststelltaste. Das ist eine Entscheidung, die man
|
||||||
|
/// treffen soll, nicht eine, die man vorfindet.
|
||||||
|
var suppressSystemOSD: Bool {
|
||||||
|
didSet {
|
||||||
|
guard suppressSystemOSD != oldValue else { return }
|
||||||
|
defaults.set(suppressSystemOSD, forKey: Keys.suppressSystemOSD)
|
||||||
|
onSuppressOSDChanged?(suppressSystemOSD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob Onyx einen eigenen Sperrbildschirm anbietet.
|
||||||
|
var lockScreenEnabled: Bool {
|
||||||
|
didSet {
|
||||||
|
guard lockScreenEnabled != oldValue else { return }
|
||||||
|
defaults.set(lockScreenEnabled, forKey: Keys.lockScreenEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob dabei das Gesicht verglichen wird.
|
||||||
|
///
|
||||||
|
/// Getrennt vom Sperrbildschirm: man kann ihn wollen und den
|
||||||
|
/// Gesichtsvergleich trotzdem nicht — er ist der Teil, der sich täuschen
|
||||||
|
/// lässt.
|
||||||
|
var faceUnlockEnabled: Bool {
|
||||||
|
didSet {
|
||||||
|
guard faceUnlockEnabled != oldValue else { return }
|
||||||
|
defaults.set(faceUnlockEnabled, forKey: Keys.faceUnlockEnabled)
|
||||||
|
onFaceUnlockChanged?(faceUnlockEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was links neben der Notch steht, in dieser Reihenfolge.
|
||||||
|
///
|
||||||
|
/// Eine Liste und keine einzelne Wahl: der Arm ist bei fünf Kacheln rund
|
||||||
|
/// 376 Punkte breit, und ein Wert braucht davon knapp sechzig.
|
||||||
|
var bandLeading: [BandAccessory] {
|
||||||
didSet {
|
didSet {
|
||||||
guard bandLeading != oldValue else { return }
|
guard bandLeading != oldValue else { return }
|
||||||
defaults.set(bandLeading.rawValue, forKey: Keys.bandLeading)
|
defaults.set(bandLeading.map(\.rawValue), forKey: Keys.bandLeading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Was rechts daneben steht.
|
/// Was rechts daneben steht.
|
||||||
var bandTrailing: BandAccessory {
|
var bandTrailing: [BandAccessory] {
|
||||||
didSet {
|
didSet {
|
||||||
guard bandTrailing != oldValue else { return }
|
guard bandTrailing != oldValue else { return }
|
||||||
defaults.set(bandTrailing.rawValue, forKey: Keys.bandTrailing)
|
defaults.set(bandTrailing.map(\.rawValue), forKey: Keys.bandTrailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wie viele Elemente je Seite höchstens.
|
||||||
|
///
|
||||||
|
/// Vier passen bei fünf Kacheln bequem. Wer weniger Kacheln zeigt, hat
|
||||||
|
/// schmalere Arme — dann wird das Letzte abgeschnitten, und das ist die
|
||||||
|
/// ehrlichere Grenze als eine Zahl, die für jede Panelbreite stimmen müsste.
|
||||||
|
static let bandLimit = 4
|
||||||
|
|
||||||
/// Notch oder Insel — die Form, die Onyx oben annimmt.
|
/// Notch oder Insel — die Form, die Onyx oben annimmt.
|
||||||
var notchStyle: NotchStyle {
|
var notchStyle: NotchStyle {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -154,6 +200,9 @@ final class AppModel {
|
|||||||
static let showsIdleIsland = "onyx.showsIdleIsland"
|
static let showsIdleIsland = "onyx.showsIdleIsland"
|
||||||
static let notchStyle = "onyx.notchStyle"
|
static let notchStyle = "onyx.notchStyle"
|
||||||
static let notchWidths = "onyx.notchWidths"
|
static let notchWidths = "onyx.notchWidths"
|
||||||
|
static let suppressSystemOSD = "onyx.suppressSystemOSD"
|
||||||
|
static let lockScreenEnabled = "onyx.lock.enabled"
|
||||||
|
static let faceUnlockEnabled = "onyx.lock.face"
|
||||||
static let bandLeading = "onyx.band.leading"
|
static let bandLeading = "onyx.band.leading"
|
||||||
static let bandTrailing = "onyx.band.trailing"
|
static let bandTrailing = "onyx.band.trailing"
|
||||||
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
|
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
|
||||||
@@ -174,12 +223,20 @@ final class AppModel {
|
|||||||
self.showsIdleIsland = defaults.object(forKey: Keys.showsIdleIsland) as? Bool ?? true
|
self.showsIdleIsland = defaults.object(forKey: Keys.showsIdleIsland) as? Bool ?? true
|
||||||
self.notchStyle = defaults.string(forKey: Keys.notchStyle)
|
self.notchStyle = defaults.string(forKey: Keys.notchStyle)
|
||||||
.flatMap(NotchStyle.init(rawValue:)) ?? .automatic
|
.flatMap(NotchStyle.init(rawValue:)) ?? .automatic
|
||||||
|
self.suppressSystemOSD = defaults.object(forKey: Keys.suppressSystemOSD) as? Bool ?? false
|
||||||
|
|
||||||
|
// Beides standardmäßig **aus**: ein Sperrbildschirm, den man nicht
|
||||||
|
// bestellt hat, ist ein Schreck, und ein Gesichtsvergleich ohne
|
||||||
|
// angelerntes Gesicht kann ohnehin nichts.
|
||||||
|
self.lockScreenEnabled = defaults.object(forKey: Keys.lockScreenEnabled) as? Bool ?? false
|
||||||
|
self.faceUnlockEnabled = defaults.object(forKey: Keys.faceUnlockEnabled) as? Bool ?? false
|
||||||
|
|
||||||
// Die bisherige feste Verdrahtung als Voreinstellung: wer nichts
|
// Die bisherige feste Verdrahtung als Voreinstellung: wer nichts
|
||||||
// einstellt, sieht, was vorher auch dastand.
|
// einstellt, sieht, was vorher auch dastand.
|
||||||
self.bandLeading = defaults.string(forKey: Keys.bandLeading)
|
self.bandLeading = (defaults.stringArray(forKey: Keys.bandLeading)
|
||||||
.flatMap(BandAccessory.init(rawValue:)) ?? .weather
|
.map { $0.compactMap(BandAccessory.init(rawValue:)) }) ?? [.weather]
|
||||||
self.bandTrailing = defaults.string(forKey: Keys.bandTrailing)
|
self.bandTrailing = (defaults.stringArray(forKey: Keys.bandTrailing)
|
||||||
.flatMap(BandAccessory.init(rawValue:)) ?? .battery
|
.map { $0.compactMap(BandAccessory.init(rawValue:)) }) ?? [.battery]
|
||||||
self.notchWidths = (defaults.dictionary(forKey: Keys.notchWidths) as? [String: Double] ?? [:])
|
self.notchWidths = (defaults.dictionary(forKey: Keys.notchWidths) as? [String: Double] ?? [:])
|
||||||
.reduce(into: [CGDirectDisplayID: CGFloat]()) { result, entry in
|
.reduce(into: [CGDirectDisplayID: CGFloat]()) { result, entry in
|
||||||
guard let id = CGDirectDisplayID(entry.key) else { return }
|
guard let id = CGDirectDisplayID(entry.key) else { return }
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
<string>Onyx zeigt dein Hintergrundbild in der Vorschau der Auslösefläche. Liegt das Bild in „Downloads", braucht Onyx dafür einmal deine Erlaubnis.</string>
|
<string>Onyx zeigt dein Hintergrundbild in der Vorschau der Auslösefläche. Liegt das Bild in „Downloads", braucht Onyx dafür einmal deine Erlaubnis.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Onyx zeigt im Mixer an, wie stark dein Mikrofon gerade ausschlägt. Gemessen wird nur, solange die Ansicht offen ist — aufgezeichnet wird nichts.</string>
|
<string>Onyx zeigt im Mixer an, wie stark dein Mikrofon gerade ausschlägt. Gemessen wird nur, solange die Ansicht offen ist — aufgezeichnet wird nichts.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Onyx vergleicht dein Gesicht mit den Aufnahmen, die du selbst angelernt hast, um den Sperrbildschirm zu öffnen. Die Bilder verlassen deinen Mac nicht — gespeichert werden nur Merkmalsvektoren im Schlüsselbund, aus denen sich kein Bild zurückgewinnen lässt.</string>
|
||||||
<key>NSAudioCaptureUsageDescription</key>
|
<key>NSAudioCaptureUsageDescription</key>
|
||||||
<string>Onyx braucht Zugriff auf die Audioausgabe, um die Lautstärke einzelner Programme zu regeln. Es wird nichts aufgezeichnet.</string>
|
<string>Onyx braucht Zugriff auf die Audioausgabe, um die Lautstärke einzelner Programme zu regeln. Es wird nichts aufgezeichnet.</string>
|
||||||
|
|
||||||
|
|||||||
+434
-18
@@ -217,22 +217,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"band.none": {
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Nichts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Nothing"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"band.temperature": {
|
"band.temperature": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -1336,6 +1320,390 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"lock.disclaimer.settings": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Dieser Sperrbildschirm ist Bequemlichkeit, keine Sicherheitsgrenze. Wer den Prozess beendet, sich per SSH anmeldet oder den Mac neu startet, kommt daran vorbei. Der Gesichtsvergleich läuft über die Webcam und lässt sich mit einem Foto täuschen — Face ID hat dafür einen Tiefensensor, die Mac-Kamera nicht. Für echten Schutz bleibt die Sperre von macOS zuständig."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "This lock screen is convenience, not a security boundary. Quitting the process, logging in over SSH or restarting the Mac gets past it. The face check runs on the webcam and can be fooled by a photo — Face ID has a depth sensor for that, the Mac camera does not. Real protection remains macOS' own lock."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.enabled": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Eigenen Sperrbildschirm benutzen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Use Onyx's lock screen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.enabled.hint": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Legt sich über den Bildschirm, damit das Entsperren per Gesicht stattfinden kann."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Covers the screen so face unlock can happen."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.enroll.cancel": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Abbrechen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Cancel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.enroll.capture": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Aufnehmen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Capture"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.enroll.close": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Schließen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Close"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.enroll.done": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Fertig angelernt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Enrolment complete."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.enroll.noFace": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Kein Gesicht im Bild"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "No face in frame"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.enroll.ready": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Gesicht erkannt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Face detected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.face.again": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Neu anlernen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Enrol again"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.face.denied": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Kamerazugriff verweigert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Camera access denied"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.face.enabled": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Gesicht vergleichen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Compare face"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.face.enroll": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Anlernen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Enrol"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.face.enrolled": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Angelerntes Gesicht"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Enrolled face"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.face.forget": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Vergessen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Forget"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.face.hint": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Braucht ein angelerntes Gesicht."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Requires an enrolled face."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.face.notEnrolled": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Noch keines angelernt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "None enrolled yet."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.face.openSettings": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Systemeinstellungen öffnen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Open System Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.now": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Jetzt sperren"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Lock now"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.now.action": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Sperren"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Lock"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.section.face": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Gesicht"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Face"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.section.screen": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Sperrbildschirm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Lock screen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.section.what": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Was das ist — und was nicht"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "What this is — and what it isn't"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu.lock": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Bildschirm sperren"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Lock screen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"menu.openPanel": {
|
"menu.openPanel": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -2509,13 +2877,13 @@
|
|||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Was auf Menüleistenhöhe links und rechts der Notch steht, wenn das Panel offen ist. Je Seite ein Wert — für mehr ist der Streifen zu schmal."
|
"value": "Was auf Menüleistenhöhe links und rechts der Notch steht, wenn das Panel offen ist. Bis zu vier je Seite — bei wenigen Kacheln sind die Arme schmaler, dann wird das Letzte abgeschnitten."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "What sits at menu bar height to the left and right of the notch while the panel is open. One value per side — the strip is too narrow for more."
|
"value": "What sits at menu bar height left and right of the notch while the panel is open. Up to four per side — with few tiles the arms are narrower and the last one gets clipped."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3317,6 +3685,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.osd.suppress": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Lautstärketasten übernehmen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Take over the volume keys"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.osd.suppress.hint": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Onyx fängt die Tasten ab und regelt selbst — dann erscheint Apples eigene Einblendung nicht mehr, weil der Tastendruck das System nie erreicht. Abschalten lässt sie sich nicht: das verhindert SIP. Braucht die Berechtigung „Bedienungshilfen“."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Onyx intercepts the keys and sets the volume itself — Apple's own overlay then stays away, because the keypress never reaches the system. Switching it off is not possible: SIP prevents that. Requires the Accessibility permission."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.permissions.calendar": {
|
"settings.permissions.calendar": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -3468,6 +3868,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.section.lock": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Sperrbildschirm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Lock screen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.section.notch": {
|
"settings.section.notch": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
import OnyxDesign
|
||||||
|
import FaceProvider
|
||||||
|
import LockProvider
|
||||||
|
|
||||||
|
/// Sperrbildschirm und Gesichtsvergleich.
|
||||||
|
///
|
||||||
|
/// **Die Einschränkung steht oben und nicht unten.** Wer diese Seite öffnet,
|
||||||
|
/// soll zuerst lesen, was das hier ist und was nicht — nicht erst, nachdem er
|
||||||
|
/// sein Gesicht angelernt hat.
|
||||||
|
struct LockSettingsView: View {
|
||||||
|
|
||||||
|
@Bindable var model: AppModel
|
||||||
|
let controller: LockController
|
||||||
|
|
||||||
|
@State private var enrollment = FaceEnrollment()
|
||||||
|
@State private var isEnrolling = false
|
||||||
|
@State private var cameraDenied = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
SettingsPage {
|
||||||
|
OnyxFormCard(String(localized: "lock.section.what"),
|
||||||
|
hint: String(localized: "lock.disclaimer.settings")) {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
OnyxFormCard(String(localized: "lock.section.screen")) {
|
||||||
|
OnyxFormRow(String(localized: "lock.enabled"),
|
||||||
|
hint: String(localized: "lock.enabled.hint")) {
|
||||||
|
Toggle("", isOn: $model.lockScreenEnabled).labelsHidden()
|
||||||
|
}
|
||||||
|
OnyxFormRow(String(localized: "lock.now")) {
|
||||||
|
Button(String(localized: "lock.now.action")) { controller.lock() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OnyxFormCard(String(localized: "lock.section.face")) {
|
||||||
|
OnyxFormRow(String(localized: "lock.face.enabled"),
|
||||||
|
hint: String(localized: "lock.face.hint")) {
|
||||||
|
Toggle("", isOn: $model.faceUnlockEnabled).labelsHidden()
|
||||||
|
.disabled(!controller.isEnrolled)
|
||||||
|
}
|
||||||
|
|
||||||
|
OnyxFormRow(String(localized: "lock.face.enrolled"),
|
||||||
|
hint: controller.isEnrolled
|
||||||
|
? nil : String(localized: "lock.face.notEnrolled")) {
|
||||||
|
HStack(spacing: Onyx.Space.lg) {
|
||||||
|
Button(controller.isEnrolled
|
||||||
|
? String(localized: "lock.face.again")
|
||||||
|
: String(localized: "lock.face.enroll")) {
|
||||||
|
Task { await beginEnrollment() }
|
||||||
|
}
|
||||||
|
if controller.isEnrolled {
|
||||||
|
Button(String(localized: "lock.face.forget"), role: .destructive) {
|
||||||
|
enrollment.reset()
|
||||||
|
model.faceUnlockEnabled = false
|
||||||
|
controller.refreshEnrollment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cameraDenied {
|
||||||
|
OnyxFormRow(String(localized: "lock.face.denied")) {
|
||||||
|
Button(String(localized: "lock.face.openSettings")) {
|
||||||
|
NSWorkspace.shared.open(URL(string:
|
||||||
|
"x-apple.systempreferences:com.apple.preference.security?Privacy_Camera")!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $isEnrolling) {
|
||||||
|
EnrollmentSheet(enrollment: enrollment) {
|
||||||
|
isEnrolling = false
|
||||||
|
// Sonst bliebe „Gesicht vergleichen" grau, obwohl gerade eines
|
||||||
|
// angelernt wurde.
|
||||||
|
controller.refreshEnrollment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginEnrollment() async {
|
||||||
|
guard await FaceCapture.requestAccess() else {
|
||||||
|
cameraDenied = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cameraDenied = false
|
||||||
|
enrollment.reset()
|
||||||
|
isEnrolling = true
|
||||||
|
enrollment.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Das Anlernen: Vorschau, Anweisung, Auslöser.
|
||||||
|
private struct EnrollmentSheet: View {
|
||||||
|
let enrollment: FaceEnrollment
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Onyx.Space.xl) {
|
||||||
|
if let failure = enrollment.failure {
|
||||||
|
Text(failure).foregroundStyle(Onyx.Color.critical)
|
||||||
|
} else if enrollment.isFinished {
|
||||||
|
VStack(spacing: Onyx.Space.md) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: Onyx.Symbol.hero))
|
||||||
|
.foregroundStyle(Onyx.Color.positive)
|
||||||
|
Text("lock.enroll.done")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CameraPreview(session: enrollment.session)
|
||||||
|
.frame(width: 320, height: 240)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Onyx.Radius.card,
|
||||||
|
style: .continuous))
|
||||||
|
|
||||||
|
Text(enrollment.pose.localizedName)
|
||||||
|
.font(Onyx.Font.headline)
|
||||||
|
|
||||||
|
Text(enrollment.seesFace ? "lock.enroll.ready" : "lock.enroll.noFace")
|
||||||
|
.font(Onyx.Font.footnote)
|
||||||
|
.foregroundStyle(enrollment.seesFace
|
||||||
|
? Onyx.Color.positive : Onyx.Color.textTertiary)
|
||||||
|
|
||||||
|
Button(String(localized: "lock.enroll.capture")) {
|
||||||
|
enrollment.captureCurrentPose()
|
||||||
|
}
|
||||||
|
.disabled(!enrollment.seesFace)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(enrollment.isFinished
|
||||||
|
? String(localized: "lock.enroll.close")
|
||||||
|
: String(localized: "lock.enroll.cancel")) {
|
||||||
|
enrollment.stop()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Onyx.Space.xxl)
|
||||||
|
.frame(width: 420)
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-14
@@ -202,12 +202,10 @@ struct BrightnessActivity: NotchActivity {
|
|||||||
|
|
||||||
/// Die Elemente, die links und rechts der Notch sitzen können.
|
/// Die Elemente, die links und rechts der Notch sitzen können.
|
||||||
///
|
///
|
||||||
/// **Eine Wahl je Seite, keine Liste.** Der Arm ist bei fünf Kacheln rund 200
|
/// Mehrere je Seite: der Arm ist bei fünf Kacheln rund 376 Punkte breit, und ein
|
||||||
/// Punkte breit und liegt auf Menüleistenhöhe — dort passt ein Wert, nicht drei.
|
/// Wert braucht davon knapp sechzig. Vier nebeneinander sind kein Gedränge,
|
||||||
/// Eine Mehrfachauswahl anzubieten hieße, ein Gedränge zu ermöglichen, das man
|
/// sondern eine Zeile.
|
||||||
/// hinterher wieder wegräumen muss.
|
|
||||||
enum BandAccessory: String, Codable, CaseIterable, Sendable {
|
enum BandAccessory: String, Codable, CaseIterable, Sendable {
|
||||||
case none
|
|
||||||
case clock
|
case clock
|
||||||
case weather
|
case weather
|
||||||
case battery
|
case battery
|
||||||
@@ -217,7 +215,6 @@ enum BandAccessory: String, Codable, CaseIterable, Sendable {
|
|||||||
|
|
||||||
var localizedName: LocalizedStringKey {
|
var localizedName: LocalizedStringKey {
|
||||||
switch self {
|
switch self {
|
||||||
case .none: "band.none"
|
|
||||||
case .clock: "band.clock"
|
case .clock: "band.clock"
|
||||||
case .weather: "band.weather"
|
case .weather: "band.weather"
|
||||||
case .battery: "band.battery"
|
case .battery: "band.battery"
|
||||||
@@ -240,22 +237,28 @@ struct BandSlotView: View {
|
|||||||
let weather: WeatherModel?
|
let weather: WeatherModel?
|
||||||
let metrics: MetricsModel?
|
let metrics: MetricsModel?
|
||||||
|
|
||||||
private var kind: BandAccessory {
|
private var kinds: [BandAccessory] {
|
||||||
edge == .leading ? model.bandLeading : model.bandTrailing
|
edge == .leading ? model.bandLeading : model.bandTrailing
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
content
|
HStack(spacing: Onyx.Space.xl) {
|
||||||
.padding(.leading, edge == .leading ? Onyx.Space.xl : Onyx.Space.lg)
|
ForEach(kinds, id: \.self) { kind in
|
||||||
.padding(.trailing, edge == .leading ? Onyx.Space.lg : Onyx.Space.xl)
|
content(kind)
|
||||||
.frame(maxWidth: .infinity, alignment: edge == .leading ? .leading : .trailing)
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, edge == .leading ? Onyx.Space.xl : Onyx.Space.lg)
|
||||||
|
.padding(.trailing, edge == .leading ? Onyx.Space.lg : Onyx.Space.xl)
|
||||||
|
.frame(maxWidth: .infinity, alignment: edge == .leading ? .leading : .trailing)
|
||||||
|
// Was nicht mehr hineinpasst, wird abgeschnitten statt den Arm über die
|
||||||
|
// Notch hinauszuschieben. Die Obergrenze in den Einstellungen sorgt
|
||||||
|
// dafür, dass es dazu im Normalfall nicht kommt.
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var content: some View {
|
private func content(_ kind: BandAccessory) -> some View {
|
||||||
switch kind {
|
switch kind {
|
||||||
case .none:
|
|
||||||
EmptyView()
|
|
||||||
case .clock:
|
case .clock:
|
||||||
// Die eine Angabe, die keine Quelle braucht.
|
// Die eine Angabe, die keine Quelle braucht.
|
||||||
TimelineView(.everyMinute) { context in
|
TimelineView(.everyMinute) { context in
|
||||||
|
|||||||
@@ -24,6 +24,14 @@
|
|||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|
||||||
|
<!-- Kamera für den Gesichtsvergleich. Ohne dieses Entitlement **und**
|
||||||
|
NSCameraUsageDescription erscheint der Dialog nicht: die Anfrage
|
||||||
|
kommt kommentarlos mit `false` zurück, und in den Systemeinstellungen
|
||||||
|
taucht Onyx nie auf. Derselbe Fallstrick wie beim Kalender, siehe
|
||||||
|
docs/spikes/E-tcc-hardened-runtime.md. -->
|
||||||
|
<key>com.apple.security.device.camera</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
<!-- Bei aktivierter Hardened Runtime verlangt TCC diese Entitlements —
|
<!-- Bei aktivierter Hardened Runtime verlangt TCC diese Entitlements —
|
||||||
auch ohne Sandbox. Das ist die verbreitete Fehlannahme: sie gelten
|
auch ohne Sandbox. Das ist die verbreitete Fehlannahme: sie gelten
|
||||||
nicht nur für sandboxed Apps.
|
nicht nur für sandboxed Apps.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import NetworkProvider
|
|||||||
import AudioProvider
|
import AudioProvider
|
||||||
import ShelfProvider
|
import ShelfProvider
|
||||||
import ClipboardProvider
|
import ClipboardProvider
|
||||||
|
import LockProvider
|
||||||
|
import FaceProvider
|
||||||
import NotesProvider
|
import NotesProvider
|
||||||
import ActivityProvider
|
import ActivityProvider
|
||||||
import OSLog
|
import OSLog
|
||||||
@@ -59,6 +61,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
/// Die Helligkeit des eingebauten Bildschirms. Auf Rechnern ohne lesbare
|
/// Die Helligkeit des eingebauten Bildschirms. Auf Rechnern ohne lesbare
|
||||||
/// Helligkeit bleibt sie leer und meldet nie etwas.
|
/// Helligkeit bleibt sie leer und meldet nie etwas.
|
||||||
private let brightness = DisplayBrightness()
|
private let brightness = DisplayBrightness()
|
||||||
|
/// Fängt die Lautstärketasten ab, damit Apples Einblendung nicht erscheint.
|
||||||
|
private let mediaKeys = MediaKeyTap()
|
||||||
|
|
||||||
|
/// Der Sperrbildschirm. Steht bereit, sperrt aber nur auf Verlangen.
|
||||||
|
private let lockController = LockController()
|
||||||
/// Der zuletzt gemeldete Stand — der erste meldet nichts, sonst begrüßte
|
/// Der zuletzt gemeldete Stand — der erste meldet nichts, sonst begrüßte
|
||||||
/// Onyx jeden Start mit der Helligkeit, die ohnehin schon eingestellt war.
|
/// Onyx jeden Start mit der Helligkeit, die ohnehin schon eingestellt war.
|
||||||
private var lastBrightness: Double?
|
private var lastBrightness: Double?
|
||||||
@@ -183,6 +190,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
model.onNotchWidthsChanged = { [weak self] widths in
|
model.onNotchWidthsChanged = { [weak self] widths in
|
||||||
self?.coordinator?.virtualWidthOverrides = widths
|
self?.coordinator?.virtualWidthOverrides = widths
|
||||||
}
|
}
|
||||||
|
// Ohne diese Zeile wäre der Schalter in den Einstellungen eine
|
||||||
|
// Behauptung: gespeichert, aber ohne Wirkung.
|
||||||
|
applyMediaKeys(model.suppressSystemOSD)
|
||||||
|
model.onSuppressOSDChanged = { [weak self] on in self?.applyMediaKeys(on) }
|
||||||
|
|
||||||
|
lockController.usesFaceUnlock = model.faceUnlockEnabled
|
||||||
|
model.onFaceUnlockChanged = { [weak self] uses in
|
||||||
|
self?.lockController.usesFaceUnlock = uses
|
||||||
|
}
|
||||||
|
|
||||||
let menuBar = MenuBarController()
|
let menuBar = MenuBarController()
|
||||||
if let metricsModel {
|
if let metricsModel {
|
||||||
@@ -299,6 +315,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
mediaKeys.stop()
|
||||||
coordinator?.stop()
|
coordinator?.stop()
|
||||||
menuBar?.stop()
|
menuBar?.stop()
|
||||||
// Der Herzschlag endet damit. Der Helfer stellt die Lüfter binnen fünf
|
// Der Herzschlag endet damit. Der Helfer stellt die Lüfter binnen fünf
|
||||||
@@ -407,6 +424,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
menu.addItem(withTitle: "Schaufenster",
|
menu.addItem(withTitle: "Schaufenster",
|
||||||
action: #selector(openGallery), keyEquivalent: "").target = self
|
action: #selector(openGallery), keyEquivalent: "").target = self
|
||||||
}
|
}
|
||||||
|
if model?.lockScreenEnabled == true {
|
||||||
|
menu.addItem(.separator())
|
||||||
|
menu.addItem(withTitle: String(localized: "menu.lock"),
|
||||||
|
action: #selector(lockNow), keyEquivalent: "l").target = self
|
||||||
|
}
|
||||||
menu.addItem(.separator())
|
menu.addItem(.separator())
|
||||||
menu.addItem(withTitle: String(localized: "menu.quit"),
|
menu.addItem(withTitle: String(localized: "menu.quit"),
|
||||||
action: #selector(quit), keyEquivalent: "q").target = self
|
action: #selector(quit), keyEquivalent: "q").target = self
|
||||||
@@ -417,6 +439,53 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
@objc private func openOnboarding() { showOnboarding() }
|
@objc private func openOnboarding() { showOnboarding() }
|
||||||
|
|
||||||
|
@objc private func lockNow() { lockController.lock() }
|
||||||
|
|
||||||
|
/// Schaltet die Übernahme der Lautstärketasten an oder aus.
|
||||||
|
///
|
||||||
|
/// **Apples Einblendung lässt sich nicht abschalten.** `launchctl bootout`
|
||||||
|
/// scheitert an SIP, egal in welcher Domäne — nachgemessen in
|
||||||
|
/// `docs/spikes/J-osd.md`. Was geht, ist ihr zuvorzukommen: das System
|
||||||
|
/// blendet nichts ein, wenn der Tastendruck es nie erreicht.
|
||||||
|
private func applyMediaKeys(_ on: Bool) {
|
||||||
|
guard on else {
|
||||||
|
mediaKeys.stop()
|
||||||
|
log.notice("Tastenabgriff aus")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard MediaKeyTap.isTrusted else {
|
||||||
|
// Ohne Bedienungshilfen tut der Abgriff nichts. Den Dialog anfragen
|
||||||
|
// statt stumm zu scheitern.
|
||||||
|
log.notice("Tastenabgriff: Bedienungshilfen fehlen — Dialog angefragt")
|
||||||
|
MediaKeyTap.requestTrust()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mediaKeys.onKey = { [weak self] key in
|
||||||
|
MainActor.assumeIsolated { self?.handleMediaKey(key) ?? false }
|
||||||
|
}
|
||||||
|
mediaKeys.start()
|
||||||
|
log.notice("Tastenabgriff \(self.mediaKeys.isRunning ? "läuft" : "NICHT gestartet", privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie macOS selbst: ein Sechzehntel je Anschlag.
|
||||||
|
private static let volumeStep = 1.0 / 16.0
|
||||||
|
|
||||||
|
private func handleMediaKey(_ key: MediaKeyTap.Key) -> Bool {
|
||||||
|
log.notice("Taste abgefangen: \(String(describing: key), privacy: .public)")
|
||||||
|
let current = systemAudio.outputVolume
|
||||||
|
switch key {
|
||||||
|
case .volumeUp:
|
||||||
|
systemAudio.setOutputVolume(min(1, current + Self.volumeStep))
|
||||||
|
case .volumeDown:
|
||||||
|
systemAudio.setOutputVolume(max(0, current - Self.volumeStep))
|
||||||
|
case .mute:
|
||||||
|
systemAudio.setOutputMuted(!systemAudio.outputMuted)
|
||||||
|
}
|
||||||
|
// Die Activity kommt von selbst: `observeVolume()` hört auf genau die
|
||||||
|
// Änderung, die wir hier gerade ausgelöst haben.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/// Meldet jede Änderung der Systemlautstärke als Live Activity an.
|
/// Meldet jede Änderung der Systemlautstärke als Live Activity an.
|
||||||
///
|
///
|
||||||
/// Über `withObservationTracking` und nicht über einen eigenen Listener:
|
/// Über `withObservationTracking` und nicht über einen eigenen Listener:
|
||||||
@@ -626,12 +695,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
settingsWindow.show(model: model, calendarModel: calendarModel,
|
settingsWindow.show(model: model, calendarModel: calendarModel,
|
||||||
weatherModel: weatherModel, metricsModel: metricsModel,
|
weatherModel: weatherModel, metricsModel: metricsModel,
|
||||||
shelfStore: shelfStore, fanControl: fanControl,
|
shelfStore: shelfStore, fanControl: fanControl,
|
||||||
|
lockController: lockController,
|
||||||
launchAtLogin: launchAtLogin,
|
launchAtLogin: launchAtLogin,
|
||||||
showOnboarding: { [weak self] in self?.showOnboarding() },
|
showOnboarding: { [weak self] in self?.showOnboarding() },
|
||||||
section: section)
|
section: section)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func quit() { NSApp.terminate(nil) }
|
@objc private func quit() { NSApp.terminate(nil) }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Inhalt des Panels. Liest `model.layout` und zeichnet sich damit neu,
|
/// Der Inhalt des Panels. Liest `model.layout` und zeichnet sich damit neu,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
case appearance
|
case appearance
|
||||||
case menubar
|
case menubar
|
||||||
case fans
|
case fans
|
||||||
|
case lockScreen
|
||||||
case permissions
|
case permissions
|
||||||
|
|
||||||
var id: Self { self }
|
var id: Self { self }
|
||||||
@@ -32,6 +33,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
case .appearance: String(localized: "settings.section.appearance")
|
case .appearance: String(localized: "settings.section.appearance")
|
||||||
case .menubar: String(localized: "settings.tab.menubar")
|
case .menubar: String(localized: "settings.tab.menubar")
|
||||||
case .fans: String(localized: "settings.tab.fans")
|
case .fans: String(localized: "settings.tab.fans")
|
||||||
|
case .lockScreen: String(localized: "settings.section.lock")
|
||||||
case .permissions: String(localized: "settings.tab.permissions")
|
case .permissions: String(localized: "settings.tab.permissions")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
case .appearance: "paintpalette.fill"
|
case .appearance: "paintpalette.fill"
|
||||||
case .menubar: "menubar.rectangle"
|
case .menubar: "menubar.rectangle"
|
||||||
case .fans: "fan.fill"
|
case .fans: "fan.fill"
|
||||||
|
case .lockScreen: "faceid"
|
||||||
case .permissions: "lock.fill"
|
case .permissions: "lock.fill"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +66,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
case .appearance: Color(red: 0.90, green: 0.60, blue: 0.28)
|
case .appearance: Color(red: 0.90, green: 0.60, blue: 0.28)
|
||||||
case .menubar: Color(red: 0.85, green: 0.45, blue: 0.66)
|
case .menubar: Color(red: 0.85, green: 0.45, blue: 0.66)
|
||||||
case .fans: Color(red: 0.33, green: 0.68, blue: 0.85)
|
case .fans: Color(red: 0.33, green: 0.68, blue: 0.85)
|
||||||
|
case .lockScreen: Color(red: 0.45, green: 0.44, blue: 0.80)
|
||||||
case .permissions: Color(red: 0.82, green: 0.37, blue: 0.35)
|
case .permissions: Color(red: 0.82, green: 0.37, blue: 0.35)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-14
@@ -1,6 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import OnyxDesign
|
import OnyxDesign
|
||||||
import OnyxNotch
|
import OnyxNotch
|
||||||
|
import LockProvider
|
||||||
import OnyxWidgetKit
|
import OnyxWidgetKit
|
||||||
import CalendarProvider
|
import CalendarProvider
|
||||||
import ShelfProvider
|
import ShelfProvider
|
||||||
@@ -16,6 +17,7 @@ struct SettingsView: View {
|
|||||||
let metricsModel: MetricsModel?
|
let metricsModel: MetricsModel?
|
||||||
let shelfStore: ShelfStore?
|
let shelfStore: ShelfStore?
|
||||||
let fanControl: FanControl
|
let fanControl: FanControl
|
||||||
|
let lockController: LockController
|
||||||
let launchAtLogin: LaunchAtLogin
|
let launchAtLogin: LaunchAtLogin
|
||||||
let showOnboarding: () -> Void
|
let showOnboarding: () -> Void
|
||||||
/// Der gewählte Bereich liegt im Fenster-Controller, damit ein erneutes
|
/// Der gewählte Bereich liegt im Fenster-Controller, damit ein erneutes
|
||||||
@@ -103,6 +105,8 @@ struct SettingsView: View {
|
|||||||
MenuBarSettings(model: model, metricsModel: metricsModel)
|
MenuBarSettings(model: model, metricsModel: metricsModel)
|
||||||
case .fans:
|
case .fans:
|
||||||
FanSettingsView(control: fanControl)
|
FanSettingsView(control: fanControl)
|
||||||
|
case .lockScreen:
|
||||||
|
LockSettingsView(model: model, controller: lockController)
|
||||||
case .permissions:
|
case .permissions:
|
||||||
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
|
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
|
||||||
}
|
}
|
||||||
@@ -179,6 +183,11 @@ private struct NotchSettings: View {
|
|||||||
.frame(width: 200)
|
.frame(width: 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OnyxFormRow(String(localized: "settings.osd.suppress"),
|
||||||
|
hint: String(localized: "settings.osd.suppress.hint")) {
|
||||||
|
Toggle("", isOn: $model.suppressSystemOSD).labelsHidden()
|
||||||
|
}
|
||||||
|
|
||||||
OnyxFormRow(String(localized: "settings.notch.style"),
|
OnyxFormRow(String(localized: "settings.notch.style"),
|
||||||
hint: String(localized: "settings.notch.style.hint")) {
|
hint: String(localized: "settings.notch.style.hint")) {
|
||||||
Picker("", selection: $model.notchStyle) {
|
Picker("", selection: $model.notchStyle) {
|
||||||
@@ -199,22 +208,10 @@ private struct NotchSettings: View {
|
|||||||
OnyxFormCard(String(localized: "settings.band"),
|
OnyxFormCard(String(localized: "settings.band"),
|
||||||
hint: String(localized: "settings.band.hint")) {
|
hint: String(localized: "settings.band.hint")) {
|
||||||
OnyxFormRow(String(localized: "settings.band.leading")) {
|
OnyxFormRow(String(localized: "settings.band.leading")) {
|
||||||
Picker("", selection: $model.bandLeading) {
|
BandEditor(items: $model.bandLeading)
|
||||||
ForEach(BandAccessory.allCases, id: \.self) { kind in
|
|
||||||
Text(kind.localizedName).tag(kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.frame(width: 200)
|
|
||||||
}
|
}
|
||||||
OnyxFormRow(String(localized: "settings.band.trailing")) {
|
OnyxFormRow(String(localized: "settings.band.trailing")) {
|
||||||
Picker("", selection: $model.bandTrailing) {
|
BandEditor(items: $model.bandTrailing)
|
||||||
ForEach(BandAccessory.allCases, id: \.self) { kind in
|
|
||||||
Text(kind.localizedName).tag(kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.frame(width: 200)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,3 +728,55 @@ private struct NotchWidthSettings: View {
|
|||||||
set: { model.setNotchWidth($0, for: id) })
|
set: { model.setNotchWidth($0, for: id) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Die Elemente einer Bandseite: was drin ist, in welcher Reihenfolge, und was
|
||||||
|
/// sich hinzufügen lässt.
|
||||||
|
///
|
||||||
|
/// Kein Ziehen zum Umsortieren. Die Liste hat höchstens vier Einträge, und für
|
||||||
|
/// vier Einträge ist Herausnehmen und wieder Hinzufügen schneller als jede
|
||||||
|
/// Ziehgeste — die man außerdem erst finden muss.
|
||||||
|
private struct BandEditor: View {
|
||||||
|
@Binding var items: [BandAccessory]
|
||||||
|
|
||||||
|
private var available: [BandAccessory] {
|
||||||
|
BandAccessory.allCases.filter { !items.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Onyx.Space.sm) {
|
||||||
|
ForEach(items, id: \.self) { item in
|
||||||
|
HStack(spacing: Onyx.Space.xs) {
|
||||||
|
Text(item.localizedName)
|
||||||
|
.font(Onyx.Font.footnote)
|
||||||
|
Button {
|
||||||
|
items.removeAll { $0 == item }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: Onyx.Symbol.caption))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Onyx.Space.sm)
|
||||||
|
.padding(.vertical, Onyx.Space.xs)
|
||||||
|
.background(.quaternary, in: .capsule)
|
||||||
|
}
|
||||||
|
|
||||||
|
if items.count < AppModel.bandLimit, !available.isEmpty {
|
||||||
|
Menu {
|
||||||
|
ForEach(available, id: \.self) { candidate in
|
||||||
|
Button { items.append(candidate) } label: {
|
||||||
|
Text(candidate.localizedName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.menuIndicator(.hidden)
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import CalendarProvider
|
|||||||
import WeatherProvider
|
import WeatherProvider
|
||||||
import MetricsProvider
|
import MetricsProvider
|
||||||
import ShelfProvider
|
import ShelfProvider
|
||||||
|
import LockProvider
|
||||||
|
|
||||||
/// Führt das Einstellungsfenster selbst.
|
/// Führt das Einstellungsfenster selbst.
|
||||||
///
|
///
|
||||||
@@ -37,6 +38,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
|
|
||||||
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
|
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
|
||||||
metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl,
|
metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl,
|
||||||
|
lockController: LockController,
|
||||||
launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void,
|
launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void,
|
||||||
section: SettingsSection = .general) {
|
section: SettingsSection = .general) {
|
||||||
self.section = section
|
self.section = section
|
||||||
@@ -58,6 +60,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
metricsModel: metricsModel,
|
metricsModel: metricsModel,
|
||||||
shelfStore: shelfStore,
|
shelfStore: shelfStore,
|
||||||
fanControl: fanControl,
|
fanControl: fanControl,
|
||||||
|
lockController: lockController,
|
||||||
launchAtLogin: launchAtLogin,
|
launchAtLogin: launchAtLogin,
|
||||||
showOnboarding: showOnboarding,
|
showOnboarding: showOnboarding,
|
||||||
section: Binding(
|
section: Binding(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ let package = Package(
|
|||||||
.library(name: "ClipboardProvider", targets: ["ClipboardProvider"]),
|
.library(name: "ClipboardProvider", targets: ["ClipboardProvider"]),
|
||||||
.library(name: "NotesProvider", targets: ["NotesProvider"]),
|
.library(name: "NotesProvider", targets: ["NotesProvider"]),
|
||||||
.library(name: "FaceProvider", targets: ["FaceProvider"]),
|
.library(name: "FaceProvider", targets: ["FaceProvider"]),
|
||||||
|
.library(name: "LockProvider", targets: ["LockProvider"]),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
||||||
@@ -77,7 +78,10 @@ let package = Package(
|
|||||||
resources: [.process("Localizable.xcstrings")]),
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
.testTarget(name: "AudioProviderTests", dependencies: ["AudioProvider"]),
|
.testTarget(name: "AudioProviderTests", dependencies: ["AudioProvider"]),
|
||||||
|
|
||||||
.target(name: "FaceProvider"),
|
.target(name: "FaceProvider",
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
|
.target(name: "LockProvider", dependencies: ["OnyxDesign", "FaceProvider"],
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
.testTarget(name: "FaceProviderTests", dependencies: ["FaceProvider"]),
|
.testTarget(name: "FaceProviderTests", dependencies: ["FaceProvider"]),
|
||||||
|
|
||||||
.target(name: "NotesProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
.target(name: "NotesProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Vision
|
||||||
|
import CoreImage
|
||||||
|
import Observation
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Face")
|
||||||
|
|
||||||
|
/// Die Kamera, und was Vision aus ihren Bildern macht.
|
||||||
|
///
|
||||||
|
/// Der Weg steht in `docs/spikes/G-gesichtsmerkmal.md`: Vision findet das
|
||||||
|
/// Gesicht, der Ausschnitt bekommt einen Merkmalsvektor, verglichen wird über
|
||||||
|
/// den Abstand. `VNGenerateFaceprintRequest` gibt es nicht — der öffentliche
|
||||||
|
/// Bildmerkmalsvektor tut es.
|
||||||
|
///
|
||||||
|
/// **Die Kamera läuft nur, solange sie gebraucht wird.** Ein Sperrbildschirm,
|
||||||
|
/// der die Kamera offen hält, ist eine leuchtende Kameraanzeige den ganzen Tag
|
||||||
|
/// — und die zu Recht.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class FaceCapture: NSObject {
|
||||||
|
|
||||||
|
/// Ob die Kamera gerade läuft.
|
||||||
|
public private(set) var isRunning = false
|
||||||
|
|
||||||
|
/// Der zuletzt gesehene Fehler, für die Anzeige beim Anlernen.
|
||||||
|
public private(set) var failure: String?
|
||||||
|
|
||||||
|
/// Wird für jedes Bild gerufen, in dem ein Gesicht war.
|
||||||
|
@ObservationIgnored public var onVector: (([Float]) -> Void)?
|
||||||
|
|
||||||
|
/// Wird gerufen, wenn ein Bild **kein** Gesicht enthielt — damit das
|
||||||
|
/// Anlernen sagen kann „bitte ins Bild".
|
||||||
|
@ObservationIgnored public var onNoFace: (() -> Void)?
|
||||||
|
|
||||||
|
/// Öffentlich nur, damit die Vorschau daran hängen kann — bedient wird sie
|
||||||
|
/// über `start()` und `stop()`.
|
||||||
|
@ObservationIgnored public let session = AVCaptureSession()
|
||||||
|
@ObservationIgnored private let queue = DispatchQueue(label: "onyx.face.capture")
|
||||||
|
/// Wird auf dem Aufnahmefaden benutzt, nicht auf dem Hauptfaden.
|
||||||
|
/// `CIContext` ist für gleichzeitige Aufrufe ausgelegt, gilt Swift aber
|
||||||
|
/// nicht als übertragbar — die Ausnahme steht hier und nicht als Sperre um
|
||||||
|
/// jeden Zugriff.
|
||||||
|
@ObservationIgnored nonisolated(unsafe) private let context = CIContext()
|
||||||
|
|
||||||
|
/// Ob überhaupt eine Kamera da ist.
|
||||||
|
public static var hasCamera: Bool {
|
||||||
|
AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) != nil
|
||||||
|
|| AVCaptureDevice.default(for: .video) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fragt die Berechtigung an. `false` heißt: ohne Kamera geht es nicht
|
||||||
|
/// weiter, und das muss die Oberfläche sagen.
|
||||||
|
public static func requestAccess() async -> Bool {
|
||||||
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||||
|
case .authorized: return true
|
||||||
|
case .notDetermined: return await AVCaptureDevice.requestAccess(for: .video)
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func start() {
|
||||||
|
guard !isRunning else { return }
|
||||||
|
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video,
|
||||||
|
position: .front)
|
||||||
|
?? AVCaptureDevice.default(for: .video) else {
|
||||||
|
failure = String(localized: "face.error.noCamera", bundle: .module)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.beginConfiguration()
|
||||||
|
session.sessionPreset = .medium
|
||||||
|
session.inputs.forEach(session.removeInput)
|
||||||
|
session.outputs.forEach(session.removeOutput)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = try AVCaptureDeviceInput(device: device)
|
||||||
|
guard session.canAddInput(input) else { throw CaptureError.cannotAddInput }
|
||||||
|
session.addInput(input)
|
||||||
|
} catch {
|
||||||
|
session.commitConfiguration()
|
||||||
|
failure = error.localizedDescription
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = AVCaptureVideoDataOutput()
|
||||||
|
output.alwaysDiscardsLateVideoFrames = true
|
||||||
|
output.setSampleBufferDelegate(self, queue: queue)
|
||||||
|
guard session.canAddOutput(output) else {
|
||||||
|
session.commitConfiguration()
|
||||||
|
failure = String(localized: "face.error.noOutput", bundle: .module)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.addOutput(output)
|
||||||
|
session.commitConfiguration()
|
||||||
|
|
||||||
|
failure = nil
|
||||||
|
isRunning = true
|
||||||
|
let session = session
|
||||||
|
queue.async { session.startRunning() }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
guard isRunning else { return }
|
||||||
|
isRunning = false
|
||||||
|
let session = session
|
||||||
|
queue.async { session.stopRunning() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CaptureError: Error { case cannotAddInput }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FaceCapture: AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||||
|
|
||||||
|
/// **Die Bildarbeit bleibt auf dem Aufnahmefaden.** Vision auf dem
|
||||||
|
/// Hauptfaden hieße, die Oberfläche dreißigmal je Sekunde anzuhalten. Auf
|
||||||
|
/// den Hauptfaden geht nur das Ergebnis — eine Zahlenreihe.
|
||||||
|
///
|
||||||
|
/// Die Warteschlange ist seriell und `alwaysDiscardsLateVideoFrames` ist
|
||||||
|
/// gesetzt: es wird immer nur ein Bild bearbeitet, und was währenddessen
|
||||||
|
/// anfällt, wird verworfen statt angehäuft.
|
||||||
|
public nonisolated func captureOutput(_ output: AVCaptureOutput,
|
||||||
|
didOutput sampleBuffer: CMSampleBuffer,
|
||||||
|
from connection: AVCaptureConnection) {
|
||||||
|
guard let pixels = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||||
|
|
||||||
|
guard let vector = vector(from: pixels) else {
|
||||||
|
Task { @MainActor [weak self] in self?.onNoFace?() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { @MainActor [weak self] in self?.onVector?(vector) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gesicht finden, ausschneiden, Merkmal rechnen. `nil`, wenn kein Gesicht
|
||||||
|
/// im Bild war.
|
||||||
|
///
|
||||||
|
/// **Zuschneiden ist nicht optional.** Ein Merkmalsvektor des ganzen Bildes
|
||||||
|
/// beschreibt zur Hälfte den Hintergrund — dann erkennt Onyx den Raum wieder
|
||||||
|
/// und nicht die Person.
|
||||||
|
private nonisolated func vector(from pixels: CVPixelBuffer) -> [Float]? {
|
||||||
|
let detect = VNDetectFaceRectanglesRequest()
|
||||||
|
try? VNImageRequestHandler(cvPixelBuffer: pixels, options: [:]).perform([detect])
|
||||||
|
guard let face = detect.results?.first else { return nil }
|
||||||
|
|
||||||
|
let image = CIImage(cvPixelBuffer: pixels)
|
||||||
|
let extent = image.extent
|
||||||
|
// Vision rechnet in Anteilen mit Ursprung unten links.
|
||||||
|
var box = CGRect(x: face.boundingBox.minX * extent.width,
|
||||||
|
y: face.boundingBox.minY * extent.height,
|
||||||
|
width: face.boundingBox.width * extent.width,
|
||||||
|
height: face.boundingBox.height * extent.height)
|
||||||
|
// Etwas Rand: das Rechteck endet an der Stirn, und Haaransatz und Kinn
|
||||||
|
// tragen zur Wiedererkennung bei.
|
||||||
|
box = box.insetBy(dx: -box.width * 0.15, dy: -box.height * 0.15).intersection(extent)
|
||||||
|
guard !box.isEmpty else { return nil }
|
||||||
|
|
||||||
|
guard let cg = context.createCGImage(image.cropped(to: box), from: box) else { return nil }
|
||||||
|
|
||||||
|
let print = VNGenerateImageFeaturePrintRequest()
|
||||||
|
try? VNImageRequestHandler(cgImage: cg, options: [:]).perform([print])
|
||||||
|
guard let observation = print.results?.first else { return nil }
|
||||||
|
return observation.onyxVector
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VNFeaturePrintObservation {
|
||||||
|
/// Der Merkmalsvektor als Zahlenreihe.
|
||||||
|
var onyxVector: [Float] {
|
||||||
|
data.withUnsafeBytes { raw in
|
||||||
|
Array(raw.bindMemory(to: Float.self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
import Observation
|
||||||
|
import OnyxDesign
|
||||||
|
|
||||||
|
/// Führt durch das Anlernen: mehrere Posen, mehrere Aufnahmen je Pose.
|
||||||
|
///
|
||||||
|
/// **Warum mehrere Posen.** Der Merkmalsvektor ist ein allgemeiner
|
||||||
|
/// Bildmerkmalsvektor und kein Gesichtsmerkmal — er reagiert auf Kopfhaltung
|
||||||
|
/// und Beleuchtung mit. Eine einzige Aufnahme beschreibt genau eine Haltung bei
|
||||||
|
/// genau einem Licht; wer sich danach anders hinsetzt, wird nicht erkannt.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class FaceEnrollment {
|
||||||
|
|
||||||
|
public enum Pose: String, CaseIterable, Sendable {
|
||||||
|
case straight, left, right
|
||||||
|
|
||||||
|
/// Aufgelöst **hier** und nicht als Schlüssel weitergereicht: die
|
||||||
|
/// Zeichenketten liegen im Bündel dieses Moduls, und wer sie anderswo
|
||||||
|
/// nachschlägt, bekommt den Schlüssel selbst zu sehen.
|
||||||
|
public var localizedName: String {
|
||||||
|
switch self {
|
||||||
|
case .straight: String(localized: "face.pose.straight", bundle: .module)
|
||||||
|
case .left: String(localized: "face.pose.left", bundle: .module)
|
||||||
|
case .right: String(localized: "face.pose.right", bundle: .module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie viele Aufnahmen je Pose.
|
||||||
|
public static let samplesPerPose = 5
|
||||||
|
|
||||||
|
public private(set) var pose: Pose = .straight
|
||||||
|
public private(set) var collected = 0
|
||||||
|
public private(set) var isFinished = false
|
||||||
|
/// Ob gerade ein Gesicht im Bild ist — für die Rückmeldung „bitte ins Bild".
|
||||||
|
public private(set) var seesFace = false
|
||||||
|
|
||||||
|
@ObservationIgnored private let capture = FaceCapture()
|
||||||
|
@ObservationIgnored private let store = FaceStore()
|
||||||
|
@ObservationIgnored private var templates: [FaceTemplate] = []
|
||||||
|
/// Erst auf Knopfdruck aufnehmen: sonst sammelt es fünf Bilder derselben
|
||||||
|
/// Sekunde ein, und das ist keine Vielfalt, sondern dasselbe fünfmal.
|
||||||
|
@ObservationIgnored private var isCollecting = false
|
||||||
|
|
||||||
|
public var failure: String? { capture.failure }
|
||||||
|
|
||||||
|
/// Für die Vorschau.
|
||||||
|
public var session: AVCaptureSession { capture.session }
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func start() {
|
||||||
|
capture.onVector = { [weak self] vector in self?.accept(vector) }
|
||||||
|
capture.onNoFace = { [weak self] in self?.seesFace = false }
|
||||||
|
capture.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() { capture.stop() }
|
||||||
|
|
||||||
|
/// Nimmt die aktuelle Pose auf.
|
||||||
|
public func captureCurrentPose() { isCollecting = true }
|
||||||
|
|
||||||
|
private func accept(_ vector: [Float]) {
|
||||||
|
seesFace = true
|
||||||
|
guard isCollecting, !isFinished else { return }
|
||||||
|
|
||||||
|
templates.append(FaceTemplate(pose: pose.rawValue, vector: vector))
|
||||||
|
collected += 1
|
||||||
|
|
||||||
|
guard collected >= Self.samplesPerPose else { return }
|
||||||
|
isCollecting = false
|
||||||
|
collected = 0
|
||||||
|
|
||||||
|
guard let next = Pose.allCases.firstIndex(of: pose).map({ $0 + 1 }),
|
||||||
|
next < Pose.allCases.count else {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pose = Pose.allCases[next]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish() {
|
||||||
|
capture.stop()
|
||||||
|
store.save(templates)
|
||||||
|
isFinished = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verwirft alles Angelernte.
|
||||||
|
public func reset() {
|
||||||
|
capture.stop()
|
||||||
|
templates = []
|
||||||
|
collected = 0
|
||||||
|
pose = .straight
|
||||||
|
isFinished = false
|
||||||
|
store.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Kameravorschau.
|
||||||
|
public struct CameraPreview: NSViewRepresentable {
|
||||||
|
private let session: AVCaptureSession
|
||||||
|
|
||||||
|
public init(session: AVCaptureSession) { self.session = session }
|
||||||
|
|
||||||
|
public func makeNSView(context: Context) -> NSView {
|
||||||
|
let view = NSView()
|
||||||
|
view.wantsLayer = true
|
||||||
|
let layer = AVCaptureVideoPreviewLayer(session: session)
|
||||||
|
layer.videoGravity = .resizeAspectFill
|
||||||
|
view.layer = layer
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateNSView(_ view: NSView, context: Context) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Face")
|
||||||
|
|
||||||
|
/// Wo die angelernten Gesichter liegen.
|
||||||
|
///
|
||||||
|
/// **In der Keychain und nicht in einer Datei.** Ein Merkmalsvektor ist kein
|
||||||
|
/// Bild und lässt sich nicht zurückverwandeln, aber er ist ein biometrisches
|
||||||
|
/// Datum: wer ihn hat, kann prüfen, ob eine bestimmte Person vor der Kamera
|
||||||
|
/// stand. Das gehört nicht in `Application Support`, wo jedes Programm des
|
||||||
|
/// Benutzers mitliest.
|
||||||
|
///
|
||||||
|
/// Dasselbe Vorgehen wie bei `CalendarrCredentials`.
|
||||||
|
public struct FaceStore: Sendable {
|
||||||
|
|
||||||
|
private static let service = "com.scarriffleservices.onyx.face"
|
||||||
|
private static let account = "templates"
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func load() -> [FaceTemplate] {
|
||||||
|
var query = Self.query()
|
||||||
|
query[kSecReturnData as String] = true
|
||||||
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
|
||||||
|
var item: CFTypeRef?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||||
|
let data = item as? Data else { return [] }
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([FaceTemplate].self, from: data)
|
||||||
|
} catch {
|
||||||
|
// Ein unlesbarer Bestand ist wie keiner: dann wird eben neu
|
||||||
|
// angelernt. Ihn stehen zu lassen hieße, dass die Anmeldung
|
||||||
|
// stumm nie wieder funktioniert.
|
||||||
|
log.error("Gesichtsdaten unlesbar — sie gelten als nicht vorhanden")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func save(_ templates: [FaceTemplate]) {
|
||||||
|
guard !templates.isEmpty else { clear(); return }
|
||||||
|
guard let data = try? JSONEncoder().encode(templates) else { return }
|
||||||
|
|
||||||
|
let query = Self.query()
|
||||||
|
let attributes: [String: Any] = [kSecValueData as String: data]
|
||||||
|
|
||||||
|
if SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecItemNotFound {
|
||||||
|
var insert = query
|
||||||
|
insert[kSecValueData as String] = data
|
||||||
|
// Nur auf diesem Gerät und nur nach dem ersten Entsperren: ein
|
||||||
|
// Gesichtsmerkmal hat auf keinem anderen Rechner etwas zu suchen.
|
||||||
|
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
let status = SecItemAdd(insert as CFDictionary, nil)
|
||||||
|
if status != errSecSuccess {
|
||||||
|
log.error("Gesichtsdaten nicht speicherbar: \(status, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clear() {
|
||||||
|
SecItemDelete(Self.query() as CFDictionary)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func query() -> [String: Any] {
|
||||||
|
[kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage": "en",
|
||||||
|
"strings": {
|
||||||
|
"face.error.noCamera": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Keine Kamera gefunden."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "No camera found."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"face.error.noOutput": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Die Kamera lässt sich nicht auslesen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The camera cannot be read."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"face.pose.left": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Dreh den Kopf leicht nach links"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Turn your head slightly left"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"face.pose.right": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Dreh den Kopf leicht nach rechts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Turn your head slightly right"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"face.pose.straight": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Schau geradeaus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Look straight ahead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
import OpenDirectory
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Face")
|
||||||
|
|
||||||
|
/// Prüft eine Passworteingabe gegen die echte Anmeldung.
|
||||||
|
///
|
||||||
|
/// **Und speichert dabei nichts.** Die Zeichenkette geht direkt in
|
||||||
|
/// `ODRecord.verifyPassword`, das Ergebnis ist ein `Bool`, und danach ist sie
|
||||||
|
/// wieder weg. Siehe `docs/spikes/I-passwortpruefung.md`.
|
||||||
|
///
|
||||||
|
/// Damit fällt der Grund weg, aus dem vergleichbare Programme sich das
|
||||||
|
/// Anmeldepasswort geben lassen und in die Keychain legen. Ein gespeichertes
|
||||||
|
/// Anmeldepasswort ist die Sorte Sicherheitsschuld, die man nicht aufnimmt,
|
||||||
|
/// wenn es auch ohne geht.
|
||||||
|
public struct PasswordVerifier: Sendable {
|
||||||
|
|
||||||
|
private let username: String
|
||||||
|
|
||||||
|
public init(username: String = NSUserName()) {
|
||||||
|
self.username = username
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true`, wenn die Eingabe stimmt.
|
||||||
|
///
|
||||||
|
/// Ein Fehler beim Nachschlagen gilt als **falsch**, nicht als richtig: wer
|
||||||
|
/// hier großzügig ist, öffnet den Sperrbildschirm, sobald der
|
||||||
|
/// Verzeichnisdienst hakt.
|
||||||
|
public func verify(_ password: String) -> Bool {
|
||||||
|
guard !password.isEmpty else { return false }
|
||||||
|
do {
|
||||||
|
let node = try ODNode(session: ODSession.default(),
|
||||||
|
type: ODNodeType(kODNodeTypeAuthentication))
|
||||||
|
let record = try node.record(withRecordType: kODRecordTypeUsers,
|
||||||
|
name: username, attributes: nil)
|
||||||
|
try record.verifyPassword(password)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob der Weg zum Benutzerdatensatz überhaupt offensteht.
|
||||||
|
///
|
||||||
|
/// Getrennt von `verify`, damit die Einstellungen es prüfen können, ohne
|
||||||
|
/// ein Passwort zu verlangen — und damit ein kaputter Verzeichnisdienst
|
||||||
|
/// nicht erst am Sperrbildschirm auffällt, wo man ihn am wenigsten
|
||||||
|
/// gebrauchen kann.
|
||||||
|
public var isAvailable: Bool {
|
||||||
|
do {
|
||||||
|
let node = try ODNode(session: ODSession.default(),
|
||||||
|
type: ODNodeType(kODNodeTypeAuthentication))
|
||||||
|
_ = try node.record(withRecordType: kODRecordTypeUsers,
|
||||||
|
name: username, attributes: nil)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
log.error("Benutzerdatensatz nicht erreichbar — Sperrbildschirm ohne Rückfall")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage": "en",
|
||||||
|
"strings": {
|
||||||
|
"lock.looking": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Sucht dein Gesicht …"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Looking for your face …"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.recognised": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Erkannt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Recognised"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.lockedOut": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Zu viele Versuche — bitte Passwort eingeben"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Too many attempts — please enter your password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.wrongPassword": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Das war nicht das richtige Passwort"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "That was not the right password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.password": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Passwort eingeben"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Enter your password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.passwordPrompt": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Passwort"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lock.disclaimer": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Dieser Sperrbildschirm ist Bequemlichkeit, keine Sicherheitsgrenze. Der Gesichtsvergleich lässt sich mit einem Foto täuschen, und wer den Rechner neu startet, kommt vorbei."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "This lock screen is convenience, not a security boundary. The face check can be fooled by a photo, and restarting the Mac gets past it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
import Observation
|
||||||
|
import FaceProvider
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Lock")
|
||||||
|
|
||||||
|
/// Führt den Sperrbildschirm: Fenster je Bildschirm, Kamera, Passwortrückfall.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class LockController {
|
||||||
|
|
||||||
|
public private(set) var isLocked = false
|
||||||
|
|
||||||
|
/// Was gerade zu sehen sein soll.
|
||||||
|
public private(set) var status: LockStatus = .idle
|
||||||
|
|
||||||
|
public enum LockStatus: Equatable, Sendable {
|
||||||
|
/// Nicht gesperrt.
|
||||||
|
case idle
|
||||||
|
/// Kamera sucht.
|
||||||
|
case looking
|
||||||
|
/// Erkannt, gleich offen.
|
||||||
|
case recognised
|
||||||
|
/// Kein Gesicht hinterlegt oder Kamera nicht verfügbar — nur Passwort.
|
||||||
|
case passwordOnly
|
||||||
|
/// Zu viele Fehlversuche.
|
||||||
|
case lockedOut
|
||||||
|
/// Ein falsches Passwort.
|
||||||
|
case wrongPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObservationIgnored private var windows: [LockWindow] = []
|
||||||
|
@ObservationIgnored private let capture = FaceCapture()
|
||||||
|
@ObservationIgnored private let store = FaceStore()
|
||||||
|
@ObservationIgnored private let verifier = PasswordVerifier()
|
||||||
|
@ObservationIgnored private var matcher: FaceMatcher?
|
||||||
|
@ObservationIgnored private var session = FaceMatcher.Session()
|
||||||
|
|
||||||
|
/// Ob Gesichtserkennung überhaupt benutzt werden soll.
|
||||||
|
public var usesFaceUnlock = true
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
refreshEnrollment()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob ein Gesicht angelernt ist.
|
||||||
|
///
|
||||||
|
/// **Gespeichert und nicht berechnet.** Als berechnete Eigenschaft las sie
|
||||||
|
/// bei jedem Aufruf den Schlüsselbund — richtig, aber für SwiftUI
|
||||||
|
/// unsichtbar: es gab nichts, woran eine Ansicht hätte merken können, dass
|
||||||
|
/// gerade ein Gesicht angelernt wurde. Der Schalter „Gesicht vergleichen"
|
||||||
|
/// blieb deshalb grau, bis man das Fenster neu öffnete.
|
||||||
|
public private(set) var isEnrolled = false
|
||||||
|
|
||||||
|
/// Liest nach, ob ein Gesicht hinterlegt ist. Nach dem Anlernen und nach
|
||||||
|
/// dem Vergessen zu rufen.
|
||||||
|
public func refreshEnrollment() {
|
||||||
|
isEnrolled = !store.load().isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sperren
|
||||||
|
|
||||||
|
public func lock() {
|
||||||
|
guard !isLocked else { return }
|
||||||
|
isLocked = true
|
||||||
|
session = FaceMatcher.Session()
|
||||||
|
|
||||||
|
let templates = store.load()
|
||||||
|
matcher = FaceMatcher(templates: templates)
|
||||||
|
|
||||||
|
for screen in NSScreen.screens {
|
||||||
|
let window = LockWindow(screen: screen)
|
||||||
|
window.contentView = NSHostingView(rootView: LockScreenView(controller: self))
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
windows.append(window)
|
||||||
|
}
|
||||||
|
// Nur eines soll die Eingabe bekommen — sonst tippt man ins Nichts.
|
||||||
|
windows.first?.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
|
guard usesFaceUnlock, !templates.isEmpty, FaceCapture.hasCamera else {
|
||||||
|
status = .passwordOnly
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startLooking()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unlock() {
|
||||||
|
guard isLocked else { return }
|
||||||
|
capture.stop()
|
||||||
|
windows.forEach { $0.orderOut(nil) }
|
||||||
|
windows.removeAll()
|
||||||
|
isLocked = false
|
||||||
|
status = .idle
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gesicht
|
||||||
|
|
||||||
|
private func startLooking() {
|
||||||
|
status = .looking
|
||||||
|
capture.onVector = { [weak self] vector in self?.evaluate(vector) }
|
||||||
|
capture.onNoFace = { }
|
||||||
|
capture.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func evaluate(_ vector: [Float]) {
|
||||||
|
guard let matcher, isLocked else { return }
|
||||||
|
switch matcher.evaluate(vector, in: &session) {
|
||||||
|
case .accepted:
|
||||||
|
status = .recognised
|
||||||
|
capture.stop()
|
||||||
|
// Ein Augenblick, damit man die Rückmeldung sieht — sonst
|
||||||
|
// verschwindet der Bildschirm, bevor klar ist, warum.
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(400))
|
||||||
|
self.unlock()
|
||||||
|
}
|
||||||
|
case .undecided:
|
||||||
|
break
|
||||||
|
case .lockedOut:
|
||||||
|
log.notice("Gesichtserkennung gesperrt — nur noch Passwort")
|
||||||
|
status = .lockedOut
|
||||||
|
capture.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Passwort
|
||||||
|
|
||||||
|
/// Prüft die Eingabe gegen die echte Anmeldung. Gespeichert wird sie nicht.
|
||||||
|
public func submit(password: String) {
|
||||||
|
guard verifier.verify(password) else {
|
||||||
|
status = status == .lockedOut ? .lockedOut : .wrongPassword
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
|
|
||||||
|
/// Was auf dem Sperrbildschirm steht: Uhr, Zustand, Passwortfeld.
|
||||||
|
struct LockScreenView: View {
|
||||||
|
|
||||||
|
let controller: LockController
|
||||||
|
|
||||||
|
@State private var password = ""
|
||||||
|
@FocusState private var isTyping: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Schwarz und kein Hintergrundbild: was hier zu sehen ist, soll
|
||||||
|
// nichts über den Rechner verraten, der darunter liegt.
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: Onyx.Space.xxl) {
|
||||||
|
Spacer()
|
||||||
|
clock
|
||||||
|
statusLine
|
||||||
|
field
|
||||||
|
Spacer()
|
||||||
|
disclaimer
|
||||||
|
}
|
||||||
|
.padding(Onyx.Space.xxl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clock: some View {
|
||||||
|
TimelineView(.everyMinute) { context in
|
||||||
|
VStack(spacing: Onyx.Space.sm) {
|
||||||
|
Text(context.date, format: .dateTime.hour().minute())
|
||||||
|
.font(.system(size: 84, weight: .thin))
|
||||||
|
.monospacedDigit()
|
||||||
|
Text(context.date, format: .dateTime.weekday(.wide).day().month(.wide))
|
||||||
|
.font(Onyx.Font.headline)
|
||||||
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var statusLine: some View {
|
||||||
|
switch controller.status {
|
||||||
|
case .looking:
|
||||||
|
label("lock.looking", symbol: "faceid", tint: .white.opacity(0.7))
|
||||||
|
case .recognised:
|
||||||
|
label("lock.recognised", symbol: "checkmark.circle.fill", tint: Onyx.Color.positive)
|
||||||
|
case .lockedOut:
|
||||||
|
label("lock.lockedOut", symbol: "exclamationmark.triangle.fill", tint: Onyx.Color.warning)
|
||||||
|
case .wrongPassword:
|
||||||
|
label("lock.wrongPassword", symbol: "xmark.circle.fill", tint: Onyx.Color.critical)
|
||||||
|
case .passwordOnly, .idle:
|
||||||
|
label("lock.password", symbol: "lock.fill", tint: .white.opacity(0.7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func label(_ key: LocalizedStringKey, symbol: String, tint: Color) -> some View {
|
||||||
|
HStack(spacing: Onyx.Space.sm) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
Text(key, bundle: .module)
|
||||||
|
}
|
||||||
|
.font(Onyx.Font.body)
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var field: some View {
|
||||||
|
SecureField("", text: $password, prompt: Text("lock.passwordPrompt", bundle: .module))
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.font(Onyx.Font.headline)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(width: 260)
|
||||||
|
.padding(.vertical, Onyx.Space.row)
|
||||||
|
.background(.white.opacity(0.10),
|
||||||
|
in: .capsule)
|
||||||
|
.focused($isTyping)
|
||||||
|
.onSubmit {
|
||||||
|
controller.submit(password: password)
|
||||||
|
password = ""
|
||||||
|
}
|
||||||
|
.onAppear { isTyping = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Einschränkung steht auf dem Bildschirm selbst.
|
||||||
|
///
|
||||||
|
/// Nicht im Kleingedruckten einer Einstellung, die man einmal gesehen hat:
|
||||||
|
/// wer sich darauf verlässt, tut es hier.
|
||||||
|
private var disclaimer: some View {
|
||||||
|
Text("lock.disclaimer", bundle: .module)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(.white.opacity(0.35))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: 420)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Das Fenster, das den Bildschirm verdeckt.
|
||||||
|
///
|
||||||
|
/// **Und was es nicht ist.** Dieses Fenster ist Bequemlichkeit, keine
|
||||||
|
/// Sicherheitsgrenze. Wer den Prozess beendet, sich per SSH anmeldet oder den
|
||||||
|
/// Rechner neu startet, kommt daran vorbei. Es ersetzt nicht die Sperre von
|
||||||
|
/// macOS — es legt sich davor, damit das Entsperren per Gesicht überhaupt
|
||||||
|
/// stattfinden kann.
|
||||||
|
///
|
||||||
|
/// Diese Einschränkung steht auch in den Einstellungen und im Onboarding, in
|
||||||
|
/// denselben klaren Worten. Ein Sperrbildschirm, den man für sicher hält,
|
||||||
|
/// obwohl er es nicht ist, ist schlechter als gar keiner.
|
||||||
|
public final class LockWindow: NSWindow {
|
||||||
|
|
||||||
|
public init(screen: NSScreen) {
|
||||||
|
super.init(contentRect: screen.frame,
|
||||||
|
styleMask: [.borderless],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false)
|
||||||
|
|
||||||
|
// Über allem, was der Benutzer sonst offen hat — auch über Vollbild.
|
||||||
|
level = .screenSaver
|
||||||
|
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary,
|
||||||
|
.ignoresCycle]
|
||||||
|
isOpaque = true
|
||||||
|
backgroundColor = .black
|
||||||
|
hasShadow = false
|
||||||
|
// Nicht in Bildschirmfotos und Bildschirmfreigaben: was hier steht, ist
|
||||||
|
// eine Passworteingabe.
|
||||||
|
sharingType = .none
|
||||||
|
isMovable = false
|
||||||
|
setFrame(screen.frame, display: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Muss Tastatureingaben annehmen können — sonst landet das Passwort im
|
||||||
|
/// Fenster darunter.
|
||||||
|
public override var canBecomeKey: Bool { true }
|
||||||
|
public override var canBecomeMain: Bool { true }
|
||||||
|
|
||||||
|
/// Schluckt, was nicht ins Passwortfeld gehört.
|
||||||
|
///
|
||||||
|
/// Vor allem `cmd+q` und `cmd+w`: ein Sperrbildschirm, den man mit einem
|
||||||
|
/// Tastendruck schließt, ist keiner.
|
||||||
|
public override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||||
|
event.modifierFlags.contains(.command) ? true : super.performKeyEquivalent(with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import AppKit
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "MediaKeys")
|
||||||
|
|
||||||
|
/// Fängt die Lautstärketasten ab, bevor das System sie sieht.
|
||||||
|
///
|
||||||
|
/// **Warum das der Weg ist.** Apples Einblendung lässt sich nicht abschalten:
|
||||||
|
/// `launchctl bootout` scheitert an SIP, egal in welcher Domäne. Was aber geht,
|
||||||
|
/// ist ihr zuvorzukommen — das System blendet nichts ein, wenn der Tastendruck
|
||||||
|
/// es nie erreicht. Siehe `docs/spikes/J-osd.md`.
|
||||||
|
///
|
||||||
|
/// **Der Preis ist die Berechtigung „Bedienungshilfen".** Ohne sie wird der Tap
|
||||||
|
/// gar nicht erst erzeugt, und dann bleibt alles wie vorher — Apples Kasten
|
||||||
|
/// erscheint, Onyx zeigt seine Activity daneben. Das ist ein hinnehmbarer
|
||||||
|
/// Zustand, aber kein stiller: wer den Schalter umlegt, muss erfahren, dass noch
|
||||||
|
/// etwas fehlt.
|
||||||
|
/// **Nicht akteursgebunden.** Der Rückruf eines `CGEventTap` läuft auf dem
|
||||||
|
/// Faden, dessen Ereignisschleife ihn angemeldet hat — hier der Hauptfaden. Den
|
||||||
|
/// Übersetzer davon zu überzeugen, kostete eine `assumeIsolated`-Umschließung
|
||||||
|
/// im Rückruf, und die brachte Swift 6.3.3 im Durchgang `SendNonSendable` zum
|
||||||
|
/// Absturz. Die Grenze liegt deshalb beim Aufrufer: `onKey` wird auf dem
|
||||||
|
/// Hauptfaden gerufen, und wer dort Akteurszustand anfasst, sagt das selbst.
|
||||||
|
public final class MediaKeyTap {
|
||||||
|
|
||||||
|
/// Welche Taste gedrückt wurde.
|
||||||
|
public enum Key: Sendable {
|
||||||
|
case volumeUp, volumeDown, mute
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wird beim Tastendruck gerufen. Gibt `true` zurück, wenn Onyx die Taste
|
||||||
|
/// übernommen hat — dann wird das Ereignis verbraucht.
|
||||||
|
///
|
||||||
|
/// Wird auf dem **Hauptfaden** gerufen.
|
||||||
|
nonisolated(unsafe) public var onKey: ((Key) -> Bool)?
|
||||||
|
|
||||||
|
nonisolated(unsafe) public private(set) var isRunning = false
|
||||||
|
|
||||||
|
/// Wird auch aus dem Rückruf heraus gelesen, der zwar auf dem Hauptfaden
|
||||||
|
/// läuft, dem Übersetzer gegenüber aber nicht als solcher gilt.
|
||||||
|
nonisolated(unsafe) private var tap: CFMachPort?
|
||||||
|
nonisolated(unsafe) private var source: CFRunLoopSource?
|
||||||
|
|
||||||
|
/// Die Kennungen aus `IOKit/hidsystem/ev_keymap.h`. Sie einzubinden zöge
|
||||||
|
/// den ganzen HID-Header nach; drei Zahlen sind billiger.
|
||||||
|
private enum KeyCode {
|
||||||
|
static let soundUp: Int32 = 0
|
||||||
|
static let soundDown: Int32 = 1
|
||||||
|
static let mute: Int32 = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Ob Onyx die Berechtigung hat.
|
||||||
|
public static var isTrusted: Bool { AXIsProcessTrusted() }
|
||||||
|
|
||||||
|
/// Fragt sie an — öffnet den Systemdialog, falls noch nicht entschieden.
|
||||||
|
public static func requestTrust() {
|
||||||
|
// Der Schlüssel als Zeichenkette statt über die globale Konstante:
|
||||||
|
// letztere ist eine veränderliche Variable und damit für Swifts
|
||||||
|
// Nebenläufigkeitsprüfung nicht sicher — der Wert ist seit jeher
|
||||||
|
// derselbe.
|
||||||
|
let options = ["AXTrustedCheckOptionPrompt": true]
|
||||||
|
_ = AXIsProcessTrustedWithOptions(options as CFDictionary)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func start() {
|
||||||
|
guard !isRunning, Self.isTrusted else { return }
|
||||||
|
|
||||||
|
// `NSSystemDefined` ist der Ereignistyp der Sondertasten.
|
||||||
|
let mask = CGEventMask(1 << 14)
|
||||||
|
// **`cghidEventTap` und nicht `cgSessionEventTap`.** An der Sitzung
|
||||||
|
// angesetzt fing der Abgriff zwar synthetische Ereignisse ab, die
|
||||||
|
// echten Tasten aber nicht: auf neueren macOS-Fassungen werden sie
|
||||||
|
// weiter vorn behandelt, bevor die Sitzung sie zu sehen bekommt. Der
|
||||||
|
// HID-Abgriff sitzt davor.
|
||||||
|
guard let tap = CGEvent.tapCreate(tap: .cghidEventTap,
|
||||||
|
place: .headInsertEventTap,
|
||||||
|
options: .defaultTap,
|
||||||
|
eventsOfInterest: mask,
|
||||||
|
callback: onyxMediaKeyCallback,
|
||||||
|
userInfo: Unmanaged.passUnretained(self).toOpaque())
|
||||||
|
else {
|
||||||
|
log.notice("Tastenabgriff nicht erzeugbar — Bedienungshilfen fehlen?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.tap = tap
|
||||||
|
source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
||||||
|
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
|
||||||
|
CGEvent.tapEnable(tap: tap, enable: true)
|
||||||
|
isRunning = true
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
guard let tap else { return }
|
||||||
|
CGEvent.tapEnable(tap: tap, enable: false)
|
||||||
|
if let source { CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .commonModes) }
|
||||||
|
self.tap = nil
|
||||||
|
source = nil
|
||||||
|
isRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entscheidet über ein einzelnes Ereignis.
|
||||||
|
fileprivate func handle(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
|
||||||
|
// Ein Tap, der zu lange braucht, wird vom System abgeschaltet. Dann
|
||||||
|
// wieder einschalten, statt stumm auszufallen.
|
||||||
|
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
||||||
|
if let port = tap { CGEvent.tapEnable(tap: port, enable: true) }
|
||||||
|
return Unmanaged.passUnretained(event)
|
||||||
|
}
|
||||||
|
guard let key = Self.key(from: event), onKey?(key) == true else {
|
||||||
|
return Unmanaged.passUnretained(event)
|
||||||
|
}
|
||||||
|
// Verbraucht: das System sieht den Tastendruck nie und blendet deshalb
|
||||||
|
// auch nichts ein.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welche Sondertaste in diesem Ereignis steckt — oder keine.
|
||||||
|
private static func key(from event: CGEvent) -> Key? {
|
||||||
|
guard let nsEvent = NSEvent(cgEvent: event), nsEvent.subtype.rawValue == 8 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let data = nsEvent.data1
|
||||||
|
let keyCode = Int32((data & 0xFFFF_0000) >> 16)
|
||||||
|
let keyState = (data & 0x0000_FF00) >> 8
|
||||||
|
// Nur das Drücken, nicht das Loslassen — sonst zählt jeder Anschlag
|
||||||
|
// doppelt.
|
||||||
|
guard keyState == 0x0A else { return nil }
|
||||||
|
|
||||||
|
return switch keyCode {
|
||||||
|
case KeyCode.soundUp: .volumeUp
|
||||||
|
case KeyCode.soundDown: .volumeDown
|
||||||
|
case KeyCode.mute: .mute
|
||||||
|
default: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Der Rückruf des Tastenabgriffs.
|
||||||
|
///
|
||||||
|
/// **Als eigene Funktion und nicht als Verschluss in `start()`.** Als Verschluss
|
||||||
|
/// brachte er Swift 6.3.3 im Durchgang `SendNonSendable` zum Absturz — kein
|
||||||
|
/// Fehler in der Logik, sondern einer im Übersetzer. Hier steht er außerhalb,
|
||||||
|
/// und der Absturz bleibt aus.
|
||||||
|
private func onyxMediaKeyCallback(proxy: CGEventTapProxy,
|
||||||
|
type: CGEventType,
|
||||||
|
event: CGEvent,
|
||||||
|
refcon: UnsafeMutableRawPointer?) -> Unmanaged<CGEvent>? {
|
||||||
|
guard let refcon else { return Unmanaged.passUnretained(event) }
|
||||||
|
let tap = Unmanaged<MediaKeyTap>.fromOpaque(refcon).takeUnretainedValue()
|
||||||
|
return tap.handle(type: type, event: event)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
import OpenDirectory
|
||||||
|
|
||||||
|
// Lässt sich ein eingegebenes Passwort gegen die echte Anmeldung prüfen, ohne
|
||||||
|
// es je zu speichern?
|
||||||
|
//
|
||||||
|
// Sapphire lässt sich das Anmeldepasswort geben und legt es in die Keychain.
|
||||||
|
// Das ist nicht nötig — und ein gespeichertes Anmeldepasswort ist genau die
|
||||||
|
// Sorte Sicherheitsschuld, die man nicht aufnehmen sollte, wenn es auch ohne
|
||||||
|
// geht.
|
||||||
|
//
|
||||||
|
// **Hier wird ausdrücklich KEIN Passwort geprüft.** Der Spike stellt nur fest,
|
||||||
|
// ob der Weg zum Benutzerdatensatz offensteht. Fehlversuche gegen ein echtes
|
||||||
|
// Konto können Sperrfristen auslösen; das ist nichts, was ein Spike anrichten
|
||||||
|
// darf.
|
||||||
|
|
||||||
|
let user = NSUserName()
|
||||||
|
print("Benutzer: \(user)")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let node = try ODNode(session: ODSession.default(), type: ODNodeType(kODNodeTypeAuthentication))
|
||||||
|
print("Knoten: erreichbar")
|
||||||
|
|
||||||
|
let record = try node.record(withRecordType: kODRecordTypeUsers, name: user, attributes: nil)
|
||||||
|
print("Datensatz: gefunden")
|
||||||
|
|
||||||
|
// Genau diese Methode würde die Prüfung machen — sie wird hier nicht gerufen.
|
||||||
|
let selector = NSSelectorFromString("verifyPassword:error:")
|
||||||
|
print("verifyPassword(_:): \(record.responds(to: selector) ? "vorhanden" : "FEHLT")")
|
||||||
|
|
||||||
|
let names = try record.values(forAttribute: kODAttributeTypeFullName) as? [String]
|
||||||
|
print("Vollständiger Name lesbar: \(names?.first != nil ? "ja" : "nein")")
|
||||||
|
|
||||||
|
print("\nBefund: Der Weg steht offen. Ein eingegebenes Passwort lässt sich")
|
||||||
|
print("gegen die echte Anmeldung prüfen, ohne es irgendwo abzulegen.")
|
||||||
|
} catch {
|
||||||
|
print("FEHLER: \(error.localizedDescription)")
|
||||||
|
print("Dann bliebe nur Authorization Services — oder, schlimmstenfalls, das")
|
||||||
|
print("Speichern des Passworts. Letzteres wäre ein Grund, die Funktion zu lassen.")
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Spike I — Passwort prüfen, ohne es zu speichern
|
||||||
|
|
||||||
|
> Im Plan heißt dieser Spike „J". Die Buchstaben in `docs/spikes/` sind
|
||||||
|
> fortlaufend vergeben; hier läuft die vorhandene Reihe weiter.
|
||||||
|
|
||||||
|
**Frage.** Sapphire lässt sich das Anmeldepasswort geben und legt es in die
|
||||||
|
Keychain. Geht es ohne — kann ein eigener Sperrbildschirm eine Eingabe gegen die
|
||||||
|
echte Anmeldung prüfen, ohne das Passwort je abzulegen?
|
||||||
|
|
||||||
|
**Gemessen am 05.09.2026**, `Spikes/password-check-probe.swift`.
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
|
||||||
|
| Prüfung | Befund |
|
||||||
|
|---|---|
|
||||||
|
| `ODNode` vom Typ `kODNodeTypeAuthentication` | erreichbar |
|
||||||
|
| Datensatz des angemeldeten Benutzers | gefunden |
|
||||||
|
| `ODRecord.verifyPassword(_:)` | vorhanden |
|
||||||
|
|
||||||
|
**Ja.** OpenDirectory ist öffentliche API. Die Eingabe geht direkt in
|
||||||
|
`verifyPassword`, das Ergebnis ist ein `Bool`, und danach ist die Zeichenkette
|
||||||
|
wieder weg.
|
||||||
|
|
||||||
|
## Was das heißt
|
||||||
|
|
||||||
|
Der Eintrag „Das Anmeldepasswort in der Keychain" bleibt auf der Liste dessen,
|
||||||
|
was **bewusst nicht** gebaut wird. Es gibt keinen Grund dafür.
|
||||||
|
|
||||||
|
Ein gespeichertes Anmeldepasswort ist die Sorte Sicherheitsschuld, die man nicht
|
||||||
|
aufnimmt, wenn es auch ohne geht: es liegt dann dauerhaft in einem Format, aus
|
||||||
|
dem es sich zurückholen lässt, und schützt ist es nur so gut wie der Prozess, der
|
||||||
|
darauf zugreifen darf.
|
||||||
|
|
||||||
|
## Was der Spike ausdrücklich **nicht** getan hat
|
||||||
|
|
||||||
|
Kein Passwort geprüft. Fehlversuche gegen ein echtes Konto können Sperrfristen
|
||||||
|
auslösen — das ist nichts, was ein Spike anrichten darf. Geprüft wurde nur, dass
|
||||||
|
der Weg offensteht.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Spike J — Apples eigene Einblendung unterdrücken
|
||||||
|
|
||||||
|
> Im Plan heißt dieser Spike „G". Die Buchstaben in `docs/spikes/` sind
|
||||||
|
> fortlaufend vergeben; hier läuft die vorhandene Reihe weiter.
|
||||||
|
|
||||||
|
**Frage.** Lässt sich `com.apple.OSDUIHelper` zuverlässig aushängen? Kommt er von
|
||||||
|
selbst zurück? Was passiert beim Ab- und Anmelden? Steht SIP im Weg?
|
||||||
|
|
||||||
|
**Gemessen am 05.09.2026**, rein lesend.
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
|
||||||
|
| Prüfung | Befund |
|
||||||
|
|---|---|
|
||||||
|
| Prozess läuft dauerhaft? | **nein** — bedarfsgesteuert, `state = not running` |
|
||||||
|
| LaunchAgent | `/System/Library/LaunchAgents/com.apple.OSDUIHelper.plist` |
|
||||||
|
| Domäne | **`gui/<uid>`** — die des angemeldeten Benutzers, nicht `system` |
|
||||||
|
| SIP | aktiviert, `OSDUIHelper.app/Contents` ist `restricted` |
|
||||||
|
|
||||||
|
## Der Schluss daraus war falsch
|
||||||
|
|
||||||
|
Aus „der Dienst liegt in der Benutzer-Domäne" habe ich geschlossen, dass
|
||||||
|
`launchctl bootout gui/<uid>/…` ihn dort aushängt, ohne Root und ohne SIP zu
|
||||||
|
berühren. **Das stimmt nicht.** Nachgemessen, diesmal ausführend:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ launchctl bootout gui/501/com.apple.OSDUIHelper
|
||||||
|
Boot-out failed: 150: Operation not permitted while System Integrity Protection is engaged
|
||||||
|
```
|
||||||
|
|
||||||
|
SIP schützt den **Dienst**, nicht nur den Dateipfad. Die Domäne spielt keine
|
||||||
|
Rolle. Ein rein lesender Spike konnte das nicht zeigen — nur der Versuch.
|
||||||
|
|
||||||
|
**Die Lehre:** Ein Spike, der die entscheidende Handlung nicht ausführt, hat die
|
||||||
|
Frage nicht beantwortet. Er hat nur die Umstände beschrieben, unter denen sie
|
||||||
|
gestellt wird. Wo die Handlung sich zurücknehmen lässt, gehört sie in den
|
||||||
|
Spike — sonst steht am Ende eine Vermutung im Dokument, die wie ein Befund
|
||||||
|
aussieht.
|
||||||
|
|
||||||
|
## Was stattdessen geht
|
||||||
|
|
||||||
|
Nicht die Einblendung abschalten, sondern **ihr zuvorkommen**: die
|
||||||
|
Lautstärketasten mit einem `CGEventTap` abfangen, die Lautstärke selbst setzen
|
||||||
|
und das Ereignis verbrauchen. Was das System nie erreicht, blendet es auch nicht
|
||||||
|
ein.
|
||||||
|
|
||||||
|
Preis: das braucht die Berechtigung „Bedienungshilfen". Ohne sie tut der Tap
|
||||||
|
nichts — dann erscheint Apples Einblendung wie gehabt, und das muss die
|
||||||
|
Oberfläche sagen, statt es stumm hinzunehmen.
|
||||||
|
|
||||||
|
## Der Vorteil des Abfangens
|
||||||
|
|
||||||
|
Es verschwindet nur die Einblendung für die Tasten, die Onyx selbst bedient.
|
||||||
|
Feststelltaste und alles andere behalten ihre — beim Aushängen des Dienstes
|
||||||
|
wären sie mit verschwunden, und für die Feststelltaste hat Onyx keinen Ersatz.
|
||||||
@@ -105,6 +105,10 @@ targets:
|
|||||||
product: ClipboardProvider
|
product: ClipboardProvider
|
||||||
- package: OnyxKit
|
- package: OnyxKit
|
||||||
product: NotesProvider
|
product: NotesProvider
|
||||||
|
- package: OnyxKit
|
||||||
|
product: FaceProvider
|
||||||
|
- package: OnyxKit
|
||||||
|
product: LockProvider
|
||||||
postBuildScripts:
|
postBuildScripts:
|
||||||
# Der Helfer und seine Plist müssen an genau diese Stellen: SMAppService
|
# Der Helfer und seine Plist müssen an genau diese Stellen: SMAppService
|
||||||
# sucht die Plist unter Contents/Library/LaunchDaemons und liest von dort
|
# sucht die Plist unter Contents/Library/LaunchDaemons und liest von dort
|
||||||
|
|||||||
Reference in New Issue
Block a user