@lattices/cli 0.4.7 → 0.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/app/Info.plist +13 -2
- package/app/Lattices.app/Contents/Info.plist +13 -2
- package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/app/Sources/AppShell/App.swift +7 -1
- package/app/Sources/AppShell/AppDelegate.swift +64 -1
- package/app/Sources/AppShell/AppShellView.swift +10 -0
- package/app/Sources/AppShell/AppUpdater.swift +216 -4
- package/app/Sources/AppShell/CliActionLauncher.swift +2 -2
- package/app/Sources/AppShell/MainView.swift +1 -1
- package/app/Sources/AppShell/Preferences.swift +29 -1
- package/app/Sources/AppShell/SettingsView.swift +576 -61
- package/app/Sources/AppShell/SettingsWindow.swift +4 -0
- package/app/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +23 -7
- package/app/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +35 -0
- package/app/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +1 -1
- package/app/Sources/Core/Input/KeyboardRemapConfig.swift +69 -0
- package/app/Sources/Core/Input/KeyboardRemapController.swift +184 -0
- package/app/Sources/Core/Input/KeyboardRemapStore.swift +84 -0
- package/app/Sources/Core/Workspace/SessionManager.swift +1 -1
- package/app/Sources/Core/Workspace/WorkspaceManager.swift +3 -3
- package/bin/lattices-app.ts +11 -0
- package/bin/lattices-dev +11 -0
- package/bin/lattices.ts +57 -17
- package/docs/app.md +30 -2
- package/docs/companion-deck.md +29 -0
- package/docs/concepts.md +5 -5
- package/docs/config.md +34 -9
- package/docs/layers.md +1 -1
- package/docs/overview.md +1 -1
- package/docs/quickstart.md +4 -4
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import SwiftUI
|
|
|
6
6
|
struct SettingsContentView: View {
|
|
7
7
|
private enum SettingsSection: String, CaseIterable, Identifiable {
|
|
8
8
|
case general
|
|
9
|
+
case companion
|
|
9
10
|
case ai
|
|
10
11
|
case search
|
|
11
12
|
case shortcuts
|
|
@@ -15,6 +16,7 @@ struct SettingsContentView: View {
|
|
|
15
16
|
var title: String {
|
|
16
17
|
switch self {
|
|
17
18
|
case .general: return "General"
|
|
19
|
+
case .companion: return "Companion"
|
|
18
20
|
case .ai: return "AI"
|
|
19
21
|
case .search: return "Search & OCR"
|
|
20
22
|
case .shortcuts: return "Shortcuts"
|
|
@@ -24,6 +26,7 @@ struct SettingsContentView: View {
|
|
|
24
26
|
var icon: String {
|
|
25
27
|
switch self {
|
|
26
28
|
case .general: return "slider.horizontal.3"
|
|
29
|
+
case .companion: return "ipad.and.iphone"
|
|
27
30
|
case .ai: return "sparkles"
|
|
28
31
|
case .search: return "text.viewfinder"
|
|
29
32
|
case .shortcuts: return "command"
|
|
@@ -33,6 +36,7 @@ struct SettingsContentView: View {
|
|
|
33
36
|
var eyebrow: String {
|
|
34
37
|
switch self {
|
|
35
38
|
case .general: return "Workspace"
|
|
39
|
+
case .companion: return "Local Bridge"
|
|
36
40
|
case .ai: return "Agents"
|
|
37
41
|
case .search: return "Indexing"
|
|
38
42
|
case .shortcuts: return "Controls"
|
|
@@ -43,6 +47,8 @@ struct SettingsContentView: View {
|
|
|
43
47
|
switch self {
|
|
44
48
|
case .general:
|
|
45
49
|
return "Terminal defaults, scan roots, window snapping, and app updates."
|
|
50
|
+
case .companion:
|
|
51
|
+
return "Local-network pairing, trusted iPad devices, and bridge security."
|
|
46
52
|
case .ai:
|
|
47
53
|
return "Claude CLI detection plus advisor model and spending controls."
|
|
48
54
|
case .search:
|
|
@@ -60,6 +66,7 @@ struct SettingsContentView: View {
|
|
|
60
66
|
@ObservedObject var workspaceManager: WorkspaceManager = .shared
|
|
61
67
|
@ObservedObject var appUpdater: AppUpdater = .shared
|
|
62
68
|
@ObservedObject var mouseShortcutStore: MouseShortcutStore = .shared
|
|
69
|
+
@ObservedObject var keyboardRemapStore: KeyboardRemapStore = .shared
|
|
63
70
|
var onBack: (() -> Void)? = nil
|
|
64
71
|
|
|
65
72
|
@State private var selectedTab: SettingsSection = .general
|
|
@@ -78,6 +85,11 @@ struct SettingsContentView: View {
|
|
|
78
85
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
79
86
|
.clipped()
|
|
80
87
|
.background(PanelBackground())
|
|
88
|
+
.onAppear {
|
|
89
|
+
if page == .companionSettings {
|
|
90
|
+
selectedTab = .companion
|
|
91
|
+
}
|
|
92
|
+
}
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
// MARK: - Back Bar
|
|
@@ -96,15 +108,17 @@ struct SettingsContentView: View {
|
|
|
96
108
|
private var backBar: some View {
|
|
97
109
|
VStack(spacing: 0) {
|
|
98
110
|
HStack(spacing: 8) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
if let onBack {
|
|
112
|
+
Button {
|
|
113
|
+
onBack()
|
|
114
|
+
} label: {
|
|
115
|
+
Image(systemName: "chevron.left")
|
|
116
|
+
.font(.system(size: 10, weight: .semibold))
|
|
117
|
+
.foregroundColor(Palette.textMuted)
|
|
118
|
+
}
|
|
119
|
+
.buttonStyle(.plain)
|
|
120
|
+
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
105
121
|
}
|
|
106
|
-
.buttonStyle(.plain)
|
|
107
|
-
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
108
122
|
|
|
109
123
|
Text(page == .docs ? "Docs" : currentTabLabel)
|
|
110
124
|
.font(Typo.heading(13))
|
|
@@ -240,6 +254,8 @@ struct SettingsContentView: View {
|
|
|
240
254
|
switch selectedTab {
|
|
241
255
|
case .general:
|
|
242
256
|
generalContent
|
|
257
|
+
case .companion:
|
|
258
|
+
companionContent
|
|
243
259
|
case .ai:
|
|
244
260
|
aiContent
|
|
245
261
|
case .search:
|
|
@@ -285,15 +301,53 @@ struct SettingsContentView: View {
|
|
|
285
301
|
.font(Typo.mono(12))
|
|
286
302
|
.foregroundColor(Palette.text)
|
|
287
303
|
Spacer()
|
|
288
|
-
Text("v\(appUpdater.currentVersion)")
|
|
304
|
+
Text("Current v\(appUpdater.currentVersion)")
|
|
289
305
|
.font(Typo.caption(10))
|
|
290
306
|
.foregroundColor(Palette.textMuted)
|
|
291
307
|
}
|
|
292
308
|
|
|
293
|
-
Text("
|
|
309
|
+
Text("Lattices can check for new signed releases and prepare the update here. You’ll confirm before the app quits and relaunches.")
|
|
294
310
|
.font(Typo.caption(10))
|
|
295
311
|
.foregroundColor(Palette.textMuted)
|
|
296
312
|
|
|
313
|
+
if let update = appUpdater.availableUpdate {
|
|
314
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
315
|
+
HStack(spacing: 6) {
|
|
316
|
+
Image(systemName: "gift.fill")
|
|
317
|
+
.font(.system(size: 10, weight: .semibold))
|
|
318
|
+
.foregroundColor(Palette.detach)
|
|
319
|
+
Text("New version v\(update.version) is ready")
|
|
320
|
+
.font(Typo.monoBold(10))
|
|
321
|
+
.foregroundColor(Palette.detach)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if !update.releaseNotes.isEmpty {
|
|
325
|
+
Text(String(update.releaseNotes.prefix(180)) + (update.releaseNotes.count > 180 ? "..." : ""))
|
|
326
|
+
.font(Typo.caption(9))
|
|
327
|
+
.foregroundColor(Palette.textMuted)
|
|
328
|
+
.lineLimit(3)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
.padding(8)
|
|
332
|
+
.background(
|
|
333
|
+
RoundedRectangle(cornerRadius: 5)
|
|
334
|
+
.fill(Palette.surfaceHov.opacity(0.65))
|
|
335
|
+
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.detach.opacity(0.35), lineWidth: 0.5))
|
|
336
|
+
)
|
|
337
|
+
} else if appUpdater.isChecking {
|
|
338
|
+
Text("Checking for updates...")
|
|
339
|
+
.font(Typo.caption(9))
|
|
340
|
+
.foregroundColor(Palette.textMuted)
|
|
341
|
+
} else if let error = appUpdater.lastError {
|
|
342
|
+
Text(error)
|
|
343
|
+
.font(Typo.caption(9))
|
|
344
|
+
.foregroundColor(Palette.detach.opacity(0.9))
|
|
345
|
+
} else if let checked = appUpdater.lastChecked {
|
|
346
|
+
Text("Last checked \(checked, style: .relative)")
|
|
347
|
+
.font(Typo.caption(9))
|
|
348
|
+
.foregroundColor(Palette.textMuted.opacity(0.8))
|
|
349
|
+
}
|
|
350
|
+
|
|
297
351
|
if let status = appUpdater.statusMessage {
|
|
298
352
|
Text(status)
|
|
299
353
|
.font(Typo.caption(9))
|
|
@@ -310,7 +364,7 @@ struct SettingsContentView: View {
|
|
|
310
364
|
Button {
|
|
311
365
|
appUpdater.promptForUpdate()
|
|
312
366
|
} label: {
|
|
313
|
-
Text(appUpdater.isUpdating ? "
|
|
367
|
+
Text(appUpdater.isUpdating ? "Preparing…" : (appUpdater.availableUpdate == nil ? "Check for Updates" : "Update to v\(appUpdater.availableUpdate?.version ?? "")"))
|
|
314
368
|
.font(Typo.monoBold(10))
|
|
315
369
|
.foregroundColor(Palette.text)
|
|
316
370
|
.padding(.horizontal, 12)
|
|
@@ -324,6 +378,43 @@ struct SettingsContentView: View {
|
|
|
324
378
|
.buttonStyle(.plain)
|
|
325
379
|
.disabled(appUpdater.isUpdating)
|
|
326
380
|
|
|
381
|
+
Button {
|
|
382
|
+
Task { await appUpdater.check() }
|
|
383
|
+
} label: {
|
|
384
|
+
Text(appUpdater.isChecking ? "Checking..." : "Check Now")
|
|
385
|
+
.font(Typo.caption(9))
|
|
386
|
+
.foregroundColor(Palette.textMuted.opacity(0.9))
|
|
387
|
+
}
|
|
388
|
+
.buttonStyle(.plain)
|
|
389
|
+
.disabled(appUpdater.isChecking)
|
|
390
|
+
|
|
391
|
+
Toggle("Auto", isOn: $appUpdater.autoCheckEnabled)
|
|
392
|
+
.font(Typo.caption(9))
|
|
393
|
+
.toggleStyle(.checkbox)
|
|
394
|
+
.foregroundColor(Palette.textMuted.opacity(0.9))
|
|
395
|
+
|
|
396
|
+
if appUpdater.availableUpdate != nil {
|
|
397
|
+
Button {
|
|
398
|
+
appUpdater.viewCurrentRelease()
|
|
399
|
+
} label: {
|
|
400
|
+
Text("Release Notes")
|
|
401
|
+
.font(Typo.caption(9))
|
|
402
|
+
.foregroundColor(Palette.textMuted.opacity(0.9))
|
|
403
|
+
}
|
|
404
|
+
.buttonStyle(.plain)
|
|
405
|
+
|
|
406
|
+
Button {
|
|
407
|
+
appUpdater.skipCurrentUpdate()
|
|
408
|
+
} label: {
|
|
409
|
+
Text("Skip")
|
|
410
|
+
.font(Typo.caption(9))
|
|
411
|
+
.foregroundColor(Palette.textMuted.opacity(0.75))
|
|
412
|
+
}
|
|
413
|
+
.buttonStyle(.plain)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
Spacer()
|
|
417
|
+
|
|
327
418
|
Text("CLI: `lattices app update`")
|
|
328
419
|
.font(Typo.caption(9))
|
|
329
420
|
.foregroundColor(Palette.textMuted.opacity(0.8))
|
|
@@ -590,6 +681,83 @@ struct SettingsContentView: View {
|
|
|
590
681
|
.foregroundColor(Palette.textMuted.opacity(0.7))
|
|
591
682
|
}
|
|
592
683
|
}
|
|
684
|
+
|
|
685
|
+
settingsCard {
|
|
686
|
+
VStack(alignment: .leading, spacing: 10) {
|
|
687
|
+
Text("Keyboard remaps")
|
|
688
|
+
.font(Typo.mono(11))
|
|
689
|
+
.foregroundColor(Palette.text)
|
|
690
|
+
|
|
691
|
+
HStack {
|
|
692
|
+
Text("Caps Lock as Hyper")
|
|
693
|
+
.font(Typo.mono(10))
|
|
694
|
+
.foregroundColor(Palette.textDim)
|
|
695
|
+
Spacer()
|
|
696
|
+
Toggle("", isOn: $prefs.keyboardRemapsEnabled)
|
|
697
|
+
.toggleStyle(.switch)
|
|
698
|
+
.controlSize(.small)
|
|
699
|
+
.labelsHidden()
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
Text("Rules live in ~/.lattices/keyboard-remaps.json. The default maps hold Caps Lock to Hyper and tap Caps Lock to Escape, so the existing Hyper shortcuts work on the laptop keyboard.")
|
|
703
|
+
.font(Typo.caption(9))
|
|
704
|
+
.foregroundColor(Palette.textMuted.opacity(0.7))
|
|
705
|
+
|
|
706
|
+
cardDivider
|
|
707
|
+
|
|
708
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
709
|
+
Text("Active remaps")
|
|
710
|
+
.font(Typo.mono(10))
|
|
711
|
+
.foregroundColor(Palette.textDim)
|
|
712
|
+
|
|
713
|
+
ForEach(keyboardRemapStore.summaryLines.prefix(4), id: \.self) { line in
|
|
714
|
+
Text(line)
|
|
715
|
+
.font(Typo.caption(9))
|
|
716
|
+
.foregroundColor(Palette.textMuted.opacity(0.78))
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if keyboardRemapStore.summaryLines.isEmpty {
|
|
720
|
+
Text("No active remaps")
|
|
721
|
+
.font(Typo.caption(9))
|
|
722
|
+
.foregroundColor(Palette.textMuted.opacity(0.6))
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
HStack(spacing: 8) {
|
|
727
|
+
Button {
|
|
728
|
+
keyboardRemapStore.openConfiguration()
|
|
729
|
+
} label: {
|
|
730
|
+
Text("Configure...")
|
|
731
|
+
.font(Typo.monoBold(10))
|
|
732
|
+
.foregroundColor(Palette.text)
|
|
733
|
+
.padding(.horizontal, 12)
|
|
734
|
+
.padding(.vertical, 4)
|
|
735
|
+
.background(
|
|
736
|
+
RoundedRectangle(cornerRadius: 4)
|
|
737
|
+
.fill(Palette.surfaceHov)
|
|
738
|
+
.overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
.buttonStyle(.plain)
|
|
742
|
+
|
|
743
|
+
Button {
|
|
744
|
+
keyboardRemapStore.restoreDefaults()
|
|
745
|
+
} label: {
|
|
746
|
+
Text("Restore Defaults")
|
|
747
|
+
.font(Typo.monoBold(10))
|
|
748
|
+
.foregroundColor(Palette.text)
|
|
749
|
+
.padding(.horizontal, 12)
|
|
750
|
+
.padding(.vertical, 4)
|
|
751
|
+
.background(
|
|
752
|
+
RoundedRectangle(cornerRadius: 4)
|
|
753
|
+
.fill(Palette.surfaceHov)
|
|
754
|
+
.overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
|
|
755
|
+
)
|
|
756
|
+
}
|
|
757
|
+
.buttonStyle(.plain)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
593
761
|
}
|
|
594
762
|
.padding(16)
|
|
595
763
|
.frame(maxWidth: 760, alignment: .leading)
|
|
@@ -597,6 +765,371 @@ struct SettingsContentView: View {
|
|
|
597
765
|
}
|
|
598
766
|
}
|
|
599
767
|
|
|
768
|
+
// MARK: - Companion
|
|
769
|
+
|
|
770
|
+
private var companionContent: some View {
|
|
771
|
+
ScrollView {
|
|
772
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
773
|
+
companionBridgeOverviewCard
|
|
774
|
+
companionTrustedDevicesCard
|
|
775
|
+
|
|
776
|
+
settingsCard {
|
|
777
|
+
VStack(alignment: .leading, spacing: 10) {
|
|
778
|
+
Text("Input")
|
|
779
|
+
.font(Typo.mono(11))
|
|
780
|
+
.foregroundColor(Palette.text)
|
|
781
|
+
|
|
782
|
+
HStack(alignment: .top, spacing: 12) {
|
|
783
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
784
|
+
Text("Trackpad proxy")
|
|
785
|
+
.font(Typo.mono(10))
|
|
786
|
+
.foregroundColor(Palette.textDim)
|
|
787
|
+
Text("Allow paired companions with the input.trackpad grant to move the Mac pointer through the encrypted bridge.")
|
|
788
|
+
.font(Typo.caption(9.5))
|
|
789
|
+
.foregroundColor(Palette.textMuted.opacity(0.75))
|
|
790
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
Spacer()
|
|
794
|
+
|
|
795
|
+
Toggle("", isOn: $prefs.companionTrackpadEnabled)
|
|
796
|
+
.toggleStyle(.switch)
|
|
797
|
+
.controlSize(.small)
|
|
798
|
+
.labelsHidden()
|
|
799
|
+
.disabled(!prefs.companionBridgeEnabled)
|
|
800
|
+
.opacity(prefs.companionBridgeEnabled ? 1 : 0.45)
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
.padding(16)
|
|
806
|
+
.frame(maxWidth: 760, alignment: .leading)
|
|
807
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private var companionBridgeOverviewCard: some View {
|
|
812
|
+
settingsCard {
|
|
813
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
814
|
+
HStack(alignment: .top, spacing: 10) {
|
|
815
|
+
RoundedRectangle(cornerRadius: 6)
|
|
816
|
+
.fill(Palette.running.opacity(0.14))
|
|
817
|
+
.overlay(
|
|
818
|
+
Image(systemName: "lock.shield")
|
|
819
|
+
.font(.system(size: 13, weight: .semibold))
|
|
820
|
+
.foregroundColor(Palette.running)
|
|
821
|
+
)
|
|
822
|
+
.frame(width: 30, height: 30)
|
|
823
|
+
|
|
824
|
+
VStack(alignment: .leading, spacing: 3) {
|
|
825
|
+
Text(prefs.companionBridgeEnabled ? "Secure local bridge" : "Local bridge off")
|
|
826
|
+
.font(Typo.mono(12))
|
|
827
|
+
.foregroundColor(Palette.text)
|
|
828
|
+
Text(prefs.companionBridgeEnabled
|
|
829
|
+
? "Bonjour discovery with explicit Mac approval, signed requests, encrypted payloads, and capability grants."
|
|
830
|
+
: "The companion bridge is not listening or advertising on the local network until you turn it on.")
|
|
831
|
+
.font(Typo.caption(10))
|
|
832
|
+
.foregroundColor(Palette.textMuted)
|
|
833
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
Spacer()
|
|
837
|
+
|
|
838
|
+
Toggle("", isOn: $prefs.companionBridgeEnabled)
|
|
839
|
+
.toggleStyle(.switch)
|
|
840
|
+
.controlSize(.small)
|
|
841
|
+
.labelsHidden()
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
cardDivider
|
|
845
|
+
|
|
846
|
+
LazyVGrid(
|
|
847
|
+
columns: [
|
|
848
|
+
GridItem(.flexible(minimum: 120), spacing: 10, alignment: .leading),
|
|
849
|
+
GridItem(.flexible(minimum: 120), spacing: 10, alignment: .leading),
|
|
850
|
+
GridItem(.flexible(minimum: 120), spacing: 10, alignment: .leading),
|
|
851
|
+
],
|
|
852
|
+
alignment: .leading,
|
|
853
|
+
spacing: 10
|
|
854
|
+
) {
|
|
855
|
+
companionBridgeFact(
|
|
856
|
+
label: "Status",
|
|
857
|
+
value: prefs.companionBridgeEnabled ? "enabled" : "off"
|
|
858
|
+
)
|
|
859
|
+
companionBridgeFact(
|
|
860
|
+
label: "Port",
|
|
861
|
+
value: String(LatticesCompanionBridgeServer.defaultPort)
|
|
862
|
+
)
|
|
863
|
+
companionBridgeFact(
|
|
864
|
+
label: "Protocol",
|
|
865
|
+
value: "v\(LatticesCompanionBridgeServer.protocolVersion)"
|
|
866
|
+
)
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
VStack(alignment: .leading, spacing: 5) {
|
|
870
|
+
Text("Enable deep link")
|
|
871
|
+
.font(Typo.mono(10))
|
|
872
|
+
.foregroundColor(Palette.textDim)
|
|
873
|
+
Text("lattices://companion/enable")
|
|
874
|
+
.font(Typo.monoBold(12))
|
|
875
|
+
.foregroundColor(Palette.text)
|
|
876
|
+
.textSelection(.enabled)
|
|
877
|
+
}
|
|
878
|
+
.padding(10)
|
|
879
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
880
|
+
.background(shortcutsInsetPanel)
|
|
881
|
+
|
|
882
|
+
VStack(alignment: .leading, spacing: 5) {
|
|
883
|
+
Text("Mac bridge fingerprint")
|
|
884
|
+
.font(Typo.mono(10))
|
|
885
|
+
.foregroundColor(Palette.textDim)
|
|
886
|
+
Text(LatticesCompanionSecurityCoordinator.shared.bridgeFingerprint)
|
|
887
|
+
.font(Typo.monoBold(13))
|
|
888
|
+
.foregroundColor(Palette.text)
|
|
889
|
+
.textSelection(.enabled)
|
|
890
|
+
}
|
|
891
|
+
.padding(10)
|
|
892
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
893
|
+
.background(shortcutsInsetPanel)
|
|
894
|
+
|
|
895
|
+
HStack(spacing: 6) {
|
|
896
|
+
ForEach(DeckBridgeCapability.defaultCompanionCapabilities, id: \.self) { capability in
|
|
897
|
+
companionCapabilityBadge(capability)
|
|
898
|
+
}
|
|
899
|
+
Spacer(minLength: 0)
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
private var companionTrustedDevicesCard: some View {
|
|
906
|
+
let trustedDevices = companionTrustedDevices(revision: companionTrustRevision)
|
|
907
|
+
|
|
908
|
+
return settingsCard {
|
|
909
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
910
|
+
HStack(alignment: .top, spacing: 12) {
|
|
911
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
912
|
+
Text("Paired devices")
|
|
913
|
+
.font(Typo.mono(12))
|
|
914
|
+
.foregroundColor(Palette.text)
|
|
915
|
+
Text("Only trusted devices can call protected deck and input routes. Pairing grants are listed per device.")
|
|
916
|
+
.font(Typo.caption(10))
|
|
917
|
+
.foregroundColor(Palette.textMuted)
|
|
918
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
Spacer()
|
|
922
|
+
|
|
923
|
+
HStack(spacing: 8) {
|
|
924
|
+
Button {
|
|
925
|
+
companionTrustRevision += 1
|
|
926
|
+
} label: {
|
|
927
|
+
Image(systemName: "arrow.clockwise")
|
|
928
|
+
.font(.system(size: 10, weight: .semibold))
|
|
929
|
+
.foregroundColor(Palette.textDim)
|
|
930
|
+
.frame(width: 24, height: 24)
|
|
931
|
+
.background(
|
|
932
|
+
RoundedRectangle(cornerRadius: 5)
|
|
933
|
+
.fill(Palette.surfaceHov)
|
|
934
|
+
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.borderLit, lineWidth: 0.5))
|
|
935
|
+
)
|
|
936
|
+
}
|
|
937
|
+
.buttonStyle(.plain)
|
|
938
|
+
|
|
939
|
+
if trustedDevices.isEmpty == false {
|
|
940
|
+
Button {
|
|
941
|
+
guard confirmForgetTrustedDevices() else { return }
|
|
942
|
+
LatticesCompanionSecurityCoordinator.shared.clearTrustedDevices()
|
|
943
|
+
companionTrustRevision += 1
|
|
944
|
+
} label: {
|
|
945
|
+
Text("Forget All")
|
|
946
|
+
.font(Typo.monoBold(10))
|
|
947
|
+
.foregroundColor(Palette.kill.opacity(0.9))
|
|
948
|
+
.padding(.horizontal, 10)
|
|
949
|
+
.padding(.vertical, 5)
|
|
950
|
+
.background(
|
|
951
|
+
RoundedRectangle(cornerRadius: 5)
|
|
952
|
+
.fill(Palette.kill.opacity(0.10))
|
|
953
|
+
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.kill.opacity(0.22), lineWidth: 0.5))
|
|
954
|
+
)
|
|
955
|
+
}
|
|
956
|
+
.buttonStyle(.plain)
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if trustedDevices.isEmpty {
|
|
962
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
963
|
+
Image(systemName: "ipad.and.iphone")
|
|
964
|
+
.font(.system(size: 18, weight: .semibold))
|
|
965
|
+
.foregroundColor(Palette.textMuted)
|
|
966
|
+
|
|
967
|
+
Text("No paired iPad or iPhone devices yet.")
|
|
968
|
+
.font(Typo.caption(10.5))
|
|
969
|
+
.foregroundColor(Palette.textMuted)
|
|
970
|
+
|
|
971
|
+
Text("Open the Lattices companion app on your iPad and select this Mac. You’ll approve the pairing prompt here.")
|
|
972
|
+
.font(Typo.caption(9.5))
|
|
973
|
+
.foregroundColor(Palette.textMuted.opacity(0.72))
|
|
974
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
975
|
+
}
|
|
976
|
+
.padding(12)
|
|
977
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
978
|
+
.background(shortcutsInsetPanel)
|
|
979
|
+
} else {
|
|
980
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
981
|
+
ForEach(trustedDevices) { device in
|
|
982
|
+
companionDeviceRow(device)
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
private func companionBridgeFact(label: String, value: String) -> some View {
|
|
991
|
+
VStack(alignment: .leading, spacing: 5) {
|
|
992
|
+
Text(label.uppercased())
|
|
993
|
+
.font(Typo.pixel(11))
|
|
994
|
+
.foregroundColor(Palette.textDim)
|
|
995
|
+
.tracking(1)
|
|
996
|
+
Text(value)
|
|
997
|
+
.font(Typo.monoBold(11))
|
|
998
|
+
.foregroundColor(Palette.text)
|
|
999
|
+
.lineLimit(1)
|
|
1000
|
+
.truncationMode(.middle)
|
|
1001
|
+
}
|
|
1002
|
+
.padding(10)
|
|
1003
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
1004
|
+
.background(shortcutsInsetPanel)
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
private func companionDeviceRow(_ device: DeckTrustedDeviceSummary) -> some View {
|
|
1008
|
+
HStack(alignment: .top, spacing: 10) {
|
|
1009
|
+
RoundedRectangle(cornerRadius: 6)
|
|
1010
|
+
.fill(Palette.surfaceHov)
|
|
1011
|
+
.overlay(
|
|
1012
|
+
Image(systemName: companionDeviceIcon(for: device.name))
|
|
1013
|
+
.font(.system(size: 13, weight: .semibold))
|
|
1014
|
+
.foregroundColor(Palette.textDim)
|
|
1015
|
+
)
|
|
1016
|
+
.frame(width: 30, height: 30)
|
|
1017
|
+
|
|
1018
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
1019
|
+
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
1020
|
+
Text(device.name)
|
|
1021
|
+
.font(Typo.monoBold(11))
|
|
1022
|
+
.foregroundColor(Palette.text)
|
|
1023
|
+
.lineLimit(1)
|
|
1024
|
+
|
|
1025
|
+
Text(device.fingerprint)
|
|
1026
|
+
.font(Typo.mono(10))
|
|
1027
|
+
.foregroundColor(Palette.textMuted)
|
|
1028
|
+
.lineLimit(1)
|
|
1029
|
+
|
|
1030
|
+
Spacer(minLength: 0)
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
HStack(spacing: 10) {
|
|
1034
|
+
Text("Paired \(relativeTimestamp(device.pairedAt))")
|
|
1035
|
+
Text("Last seen \(relativeTimestamp(device.lastSeenAt))")
|
|
1036
|
+
}
|
|
1037
|
+
.font(Typo.caption(9.5))
|
|
1038
|
+
.foregroundColor(Palette.textMuted.opacity(0.78))
|
|
1039
|
+
|
|
1040
|
+
HStack(spacing: 6) {
|
|
1041
|
+
ForEach(device.capabilities, id: \.self) { capability in
|
|
1042
|
+
companionCapabilityBadge(capability)
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
Spacer(minLength: 0)
|
|
1048
|
+
|
|
1049
|
+
Button {
|
|
1050
|
+
guard confirmRevokeTrustedDevice(device) else { return }
|
|
1051
|
+
LatticesCompanionSecurityCoordinator.shared.revokeTrustedDevice(id: device.id)
|
|
1052
|
+
companionTrustRevision += 1
|
|
1053
|
+
} label: {
|
|
1054
|
+
HStack(spacing: 5) {
|
|
1055
|
+
Image(systemName: "xmark.shield")
|
|
1056
|
+
.font(.system(size: 10, weight: .semibold))
|
|
1057
|
+
Text("Revoke")
|
|
1058
|
+
.font(Typo.monoBold(9.5))
|
|
1059
|
+
}
|
|
1060
|
+
.foregroundColor(Palette.kill.opacity(0.95))
|
|
1061
|
+
.padding(.horizontal, 8)
|
|
1062
|
+
.padding(.vertical, 5)
|
|
1063
|
+
.background(
|
|
1064
|
+
RoundedRectangle(cornerRadius: 5)
|
|
1065
|
+
.fill(Palette.kill.opacity(0.10))
|
|
1066
|
+
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.kill.opacity(0.22), lineWidth: 0.5))
|
|
1067
|
+
)
|
|
1068
|
+
}
|
|
1069
|
+
.buttonStyle(.plain)
|
|
1070
|
+
.help("Revoke this paired device")
|
|
1071
|
+
}
|
|
1072
|
+
.padding(12)
|
|
1073
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
1074
|
+
.background(shortcutsInsetPanel)
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
private func companionCapabilityBadge(_ capability: String) -> some View {
|
|
1078
|
+
Text(companionCapabilityLabel(capability))
|
|
1079
|
+
.font(Typo.monoBold(9))
|
|
1080
|
+
.foregroundColor(Palette.running.opacity(0.92))
|
|
1081
|
+
.padding(.horizontal, 7)
|
|
1082
|
+
.padding(.vertical, 3)
|
|
1083
|
+
.background(
|
|
1084
|
+
Capsule()
|
|
1085
|
+
.fill(Palette.running.opacity(0.10))
|
|
1086
|
+
.overlay(Capsule().strokeBorder(Palette.running.opacity(0.18), lineWidth: 0.5))
|
|
1087
|
+
)
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
private func companionCapabilityLabel(_ capability: String) -> String {
|
|
1091
|
+
switch capability {
|
|
1092
|
+
case DeckBridgeCapability.deckRead:
|
|
1093
|
+
return "Deck Read"
|
|
1094
|
+
case DeckBridgeCapability.deckPerform:
|
|
1095
|
+
return "Deck Actions"
|
|
1096
|
+
case DeckBridgeCapability.inputTrackpad:
|
|
1097
|
+
return "Trackpad"
|
|
1098
|
+
default:
|
|
1099
|
+
return capability
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
private func companionDeviceIcon(for name: String) -> String {
|
|
1104
|
+
name.localizedCaseInsensitiveContains("ipad") ? "ipad" : "iphone"
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
private func confirmForgetTrustedDevices() -> Bool {
|
|
1108
|
+
let alert = NSAlert()
|
|
1109
|
+
alert.alertStyle = .warning
|
|
1110
|
+
alert.messageText = "Forget all paired companion devices?"
|
|
1111
|
+
alert.informativeText = "Your iPad or iPhone will need to pair again before it can control Lattices."
|
|
1112
|
+
alert.addButton(withTitle: "Forget Devices")
|
|
1113
|
+
alert.addButton(withTitle: "Cancel")
|
|
1114
|
+
return alert.runModal() == .alertFirstButtonReturn
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
private func confirmRevokeTrustedDevice(_ device: DeckTrustedDeviceSummary) -> Bool {
|
|
1118
|
+
let alert = NSAlert()
|
|
1119
|
+
alert.alertStyle = .warning
|
|
1120
|
+
alert.messageText = "Revoke \(device.name)?"
|
|
1121
|
+
alert.informativeText = """
|
|
1122
|
+
This removes the paired-device trust record for \(device.name).
|
|
1123
|
+
|
|
1124
|
+
Fingerprint: \(device.fingerprint)
|
|
1125
|
+
|
|
1126
|
+
The device will need to pair again before it can control Lattices.
|
|
1127
|
+
"""
|
|
1128
|
+
alert.addButton(withTitle: "Revoke Device")
|
|
1129
|
+
alert.addButton(withTitle: "Cancel")
|
|
1130
|
+
return alert.runModal() == .alertFirstButtonReturn
|
|
1131
|
+
}
|
|
1132
|
+
|
|
600
1133
|
// MARK: - AI
|
|
601
1134
|
|
|
602
1135
|
private var aiContent: some View {
|
|
@@ -1249,7 +1782,7 @@ struct SettingsContentView: View {
|
|
|
1249
1782
|
let layout = LatticesCompanionCockpitCatalog.normalized(prefs.companionCockpitLayout)
|
|
1250
1783
|
let selectedPage = layout.pages.first(where: { $0.id == selectedCompanionCockpitPageID }) ?? layout.pages.first
|
|
1251
1784
|
let categories = LatticesCompanionShortcutCategory.allCases
|
|
1252
|
-
let
|
|
1785
|
+
let trustedDeviceCount = companionTrustedDevices(revision: companionTrustRevision).count
|
|
1253
1786
|
|
|
1254
1787
|
return shortcutSectionCard(
|
|
1255
1788
|
title: "Companion Cockpit",
|
|
@@ -1273,63 +1806,45 @@ struct SettingsContentView: View {
|
|
|
1273
1806
|
Toggle("", isOn: $prefs.companionTrackpadEnabled)
|
|
1274
1807
|
.toggleStyle(.switch)
|
|
1275
1808
|
.labelsHidden()
|
|
1809
|
+
.disabled(!prefs.companionBridgeEnabled)
|
|
1810
|
+
.opacity(prefs.companionBridgeEnabled ? 1 : 0.45)
|
|
1276
1811
|
}
|
|
1277
1812
|
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
Text("New companions must be approved on the Mac before they can send encrypted bridge requests.")
|
|
1285
|
-
.font(Typo.caption(10.5))
|
|
1286
|
-
.foregroundColor(Palette.textMuted)
|
|
1287
|
-
.fixedSize(horizontal: false, vertical: true)
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
Spacer()
|
|
1291
|
-
|
|
1292
|
-
if trustedDevices.isEmpty == false {
|
|
1293
|
-
Button("Forget All") {
|
|
1294
|
-
LatticesCompanionSecurityCoordinator.shared.clearTrustedDevices()
|
|
1295
|
-
companionTrustRevision += 1
|
|
1296
|
-
}
|
|
1297
|
-
.buttonStyle(.plain)
|
|
1813
|
+
HStack(alignment: .center, spacing: 12) {
|
|
1814
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
1815
|
+
Text("Pairing and trust")
|
|
1816
|
+
.font(Typo.monoBold(11))
|
|
1817
|
+
.foregroundColor(Palette.text)
|
|
1818
|
+
Text("\(trustedDeviceCount) paired \(trustedDeviceCount == 1 ? "device" : "devices"). Revoke devices and review bridge grants in Companion settings.")
|
|
1298
1819
|
.font(Typo.caption(10.5))
|
|
1299
|
-
.foregroundColor(Palette.
|
|
1300
|
-
|
|
1820
|
+
.foregroundColor(Palette.textMuted)
|
|
1821
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
1301
1822
|
}
|
|
1302
1823
|
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
.foregroundColor(Palette.textDim)
|
|
1314
|
-
.frame(width: 14)
|
|
1315
|
-
|
|
1316
|
-
VStack(alignment: .leading, spacing: 2) {
|
|
1317
|
-
Text(device.name)
|
|
1318
|
-
.font(Typo.caption(11))
|
|
1319
|
-
.foregroundColor(Palette.text)
|
|
1320
|
-
Text("\(device.fingerprint) · Last seen \(relativeTimestamp(device.lastSeenAt))")
|
|
1321
|
-
.font(Typo.caption(10))
|
|
1322
|
-
.foregroundColor(Palette.textMuted)
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
Spacer(minLength: 0)
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1824
|
+
Spacer()
|
|
1825
|
+
|
|
1826
|
+
Button {
|
|
1827
|
+
selectedTab = .companion
|
|
1828
|
+
} label: {
|
|
1829
|
+
HStack(spacing: 5) {
|
|
1830
|
+
Image(systemName: "ipad.and.iphone")
|
|
1831
|
+
.font(.system(size: 10, weight: .semibold))
|
|
1832
|
+
Text("Manage")
|
|
1833
|
+
.font(Typo.monoBold(10))
|
|
1328
1834
|
}
|
|
1835
|
+
.foregroundColor(Palette.text)
|
|
1836
|
+
.padding(.horizontal, 10)
|
|
1837
|
+
.padding(.vertical, 5)
|
|
1838
|
+
.background(
|
|
1839
|
+
RoundedRectangle(cornerRadius: 5)
|
|
1840
|
+
.fill(Palette.surfaceHov)
|
|
1841
|
+
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.borderLit, lineWidth: 0.5))
|
|
1842
|
+
)
|
|
1329
1843
|
}
|
|
1330
|
-
.
|
|
1331
|
-
.background(shortcutsInsetPanel)
|
|
1844
|
+
.buttonStyle(.plain)
|
|
1332
1845
|
}
|
|
1846
|
+
.padding(12)
|
|
1847
|
+
.background(shortcutsInsetPanel)
|
|
1333
1848
|
|
|
1334
1849
|
if let selectedPage {
|
|
1335
1850
|
Picker("Companion page", selection: $selectedCompanionCockpitPageID) {
|