@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.
Files changed (32) hide show
  1. package/README.md +8 -6
  2. package/app/Info.plist +13 -2
  3. package/app/Lattices.app/Contents/Info.plist +13 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Sources/AppShell/App.swift +7 -1
  6. package/app/Sources/AppShell/AppDelegate.swift +64 -1
  7. package/app/Sources/AppShell/AppShellView.swift +10 -0
  8. package/app/Sources/AppShell/AppUpdater.swift +216 -4
  9. package/app/Sources/AppShell/CliActionLauncher.swift +2 -2
  10. package/app/Sources/AppShell/MainView.swift +1 -1
  11. package/app/Sources/AppShell/Preferences.swift +29 -1
  12. package/app/Sources/AppShell/SettingsView.swift +576 -61
  13. package/app/Sources/AppShell/SettingsWindow.swift +4 -0
  14. package/app/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +23 -7
  15. package/app/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +35 -0
  16. package/app/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +1 -1
  17. package/app/Sources/Core/Input/KeyboardRemapConfig.swift +69 -0
  18. package/app/Sources/Core/Input/KeyboardRemapController.swift +184 -0
  19. package/app/Sources/Core/Input/KeyboardRemapStore.swift +84 -0
  20. package/app/Sources/Core/Workspace/SessionManager.swift +1 -1
  21. package/app/Sources/Core/Workspace/WorkspaceManager.swift +3 -3
  22. package/bin/lattices-app.ts +11 -0
  23. package/bin/lattices-dev +11 -0
  24. package/bin/lattices.ts +57 -17
  25. package/docs/app.md +30 -2
  26. package/docs/companion-deck.md +29 -0
  27. package/docs/concepts.md +5 -5
  28. package/docs/config.md +34 -9
  29. package/docs/layers.md +1 -1
  30. package/docs/overview.md +1 -1
  31. package/docs/quickstart.md +4 -4
  32. package/package.json +1 -1
@@ -14,6 +14,10 @@ final class SettingsWindowController {
14
14
  ScreenMapWindowController.shared.showPage(.settings)
15
15
  }
16
16
 
17
+ func showCompanion() {
18
+ ScreenMapWindowController.shared.showPage(.companionSettings)
19
+ }
20
+
17
21
  func close() {
18
22
  ScreenMapWindowController.shared.close()
19
23
  }
@@ -7,6 +7,8 @@ final class LatticesCompanionBridgeServer: NSObject {
7
7
 
8
8
  static let bonjourType = "_lattices-companion._tcp."
9
9
  static let defaultPort: UInt16 = 5287
10
+ static let protocolVersion = "1"
11
+ static let maxBodyBytes = 512 * 1024
10
12
 
11
13
  private let queue = DispatchQueue(label: "lattices.companion.bridge", qos: .userInitiated)
12
14
  private let encoder = JSONEncoder()
@@ -101,12 +103,14 @@ private extension LatticesCompanionBridgeServer {
101
103
  let serviceType: String
102
104
  let hostName: String
103
105
  let port: UInt16
106
+ let protocolVersion: String
104
107
  let version: String
105
108
  let mode: String
106
109
  let bridgePublicKey: String
107
110
  let bridgeFingerprint: String
108
111
  let requestSigningRequired: Bool
109
112
  let payloadEncryptionRequired: Bool
113
+ let capabilities: [String]
110
114
  }
111
115
 
112
116
  func publishBonjour() {
@@ -118,6 +122,13 @@ private extension LatticesCompanionBridgeServer {
118
122
  port: Int32(Self.defaultPort)
119
123
  )
120
124
  service.includesPeerToPeer = true
125
+ service.setTXTRecord(NetService.data(fromTXTRecord: [
126
+ "v": Data(Self.protocolVersion.utf8),
127
+ "mode": Data("local-network-secure".utf8),
128
+ "fp": Data(LatticesCompanionSecurityCoordinator.shared.bridgeFingerprint.utf8),
129
+ "sec": Data("signed,encrypted".utf8),
130
+ "cap": Data(DeckBridgeCapability.defaultCompanionCapabilities.joined(separator: ",").utf8),
131
+ ]))
121
132
  service.publish()
122
133
  self.service = service
123
134
  }
@@ -164,7 +175,7 @@ private extension LatticesCompanionBridgeServer {
164
175
  } catch let error as LatticesCompanionSecurityError {
165
176
  let status: Int
166
177
  switch error {
167
- case .untrustedDevice:
178
+ case .untrustedDevice, .insufficientCapability:
168
179
  status = 403
169
180
  case .missingHeader, .staleRequest, .replayedRequest, .invalidSignature, .invalidEnvelope, .invalidDeviceKey:
170
181
  status = 401
@@ -185,12 +196,14 @@ private extension LatticesCompanionBridgeServer {
185
196
  serviceType: Self.bonjourType,
186
197
  hostName: localHostName(),
187
198
  port: Self.defaultPort,
199
+ protocolVersion: Self.protocolVersion,
188
200
  version: LatticesRuntime.appVersion,
189
201
  mode: "local-network-secure",
190
202
  bridgePublicKey: LatticesCompanionSecurityCoordinator.shared.bridgePublicKeyBase64,
191
203
  bridgeFingerprint: LatticesCompanionSecurityCoordinator.shared.bridgeFingerprint,
192
204
  requestSigningRequired: security.requestSigningRequired,
193
- payloadEncryptionRequired: security.payloadEncryptionRequired
205
+ payloadEncryptionRequired: security.payloadEncryptionRequired,
206
+ capabilities: DeckBridgeCapability.defaultCompanionCapabilities
194
207
  )
195
208
  try sendJSON(status: 200, value: response, to: fd)
196
209
 
@@ -204,7 +217,7 @@ private extension LatticesCompanionBridgeServer {
204
217
  try sendJSON(status: status, value: response, to: fd)
205
218
 
206
219
  case ("GET", "/deck/snapshot"):
207
- let auth = try authorizeProtectedRequest(request)
220
+ let auth = try authorizeProtectedRequest(request, requiredCapability: DeckBridgeCapability.deckRead)
208
221
  let snapshot = try LatticesDeckHost.shared.runtimeSnapshotSync()
209
222
  let response = try LatticesCompanionSecurityCoordinator.shared.encodeProtectedResponse(
210
223
  snapshot,
@@ -215,7 +228,7 @@ private extension LatticesCompanionBridgeServer {
215
228
  try sendJSON(status: 200, value: response, to: fd)
216
229
 
217
230
  case ("POST", "/deck/perform"):
218
- let auth = try authorizeProtectedRequest(request)
231
+ let auth = try authorizeProtectedRequest(request, requiredCapability: DeckBridgeCapability.deckPerform)
219
232
  let action = try LatticesCompanionSecurityCoordinator.shared.decodeProtectedBody(
220
233
  DeckActionRequest.self,
221
234
  body: request.body,
@@ -233,7 +246,7 @@ private extension LatticesCompanionBridgeServer {
233
246
  try sendJSON(status: 200, value: response, to: fd)
234
247
 
235
248
  case ("POST", "/deck/trackpad"):
236
- let auth = try authorizeProtectedRequest(request)
249
+ let auth = try authorizeProtectedRequest(request, requiredCapability: DeckBridgeCapability.inputTrackpad)
237
250
  let eventRequest = try LatticesCompanionSecurityCoordinator.shared.decodeProtectedBody(
238
251
  DeckTrackpadEventRequest.self,
239
252
  body: request.body,
@@ -255,17 +268,19 @@ private extension LatticesCompanionBridgeServer {
255
268
  }
256
269
  }
257
270
 
258
- func authorizeProtectedRequest(_ request: HTTPRequest) throws -> AuthorizedBridgeRequest {
271
+ func authorizeProtectedRequest(_ request: HTTPRequest, requiredCapability: String) throws -> AuthorizedBridgeRequest {
259
272
  let security = LatticesDeckHost.shared.securityConfiguration
260
273
  guard security.requestSigningRequired else {
261
274
  throw LatticesCompanionSecurityError.untrustedDevice
262
275
  }
263
- return try LatticesCompanionSecurityCoordinator.shared.authorize(
276
+ let auth = try LatticesCompanionSecurityCoordinator.shared.authorize(
264
277
  method: request.method,
265
278
  path: request.path,
266
279
  headers: request.headers,
267
280
  body: request.body
268
281
  )
282
+ try LatticesCompanionSecurityCoordinator.shared.requireCapability(requiredCapability, for: auth)
283
+ return auth
269
284
  }
270
285
 
271
286
  func readRequest(from fd: Int32) -> HTTPRequest? {
@@ -312,6 +327,7 @@ private extension LatticesCompanionBridgeServer {
312
327
  }
313
328
 
314
329
  let contentLength = Int(headers["content-length"] ?? "") ?? 0
330
+ guard contentLength <= Self.maxBodyBytes else { return nil }
315
331
  var body = Data(buffer[headerRange.upperBound...])
316
332
  while body.count < contentLength {
317
333
  guard let count = readChunk(
@@ -12,6 +12,7 @@ enum LatticesCompanionSecurityError: LocalizedError {
12
12
  case invalidSignature
13
13
  case invalidEnvelope
14
14
  case invalidDeviceKey
15
+ case insufficientCapability(String)
15
16
 
16
17
  var errorDescription: String? {
17
18
  switch self {
@@ -29,6 +30,8 @@ enum LatticesCompanionSecurityError: LocalizedError {
29
30
  return "The bridge payload could not be decrypted."
30
31
  case .invalidDeviceKey:
31
32
  return "The device pairing key is invalid."
33
+ case .insufficientCapability(let capability):
34
+ return "This trusted device is missing the required bridge capability: \(capability)."
32
35
  }
33
36
  }
34
37
  }
@@ -40,6 +43,7 @@ struct LatticesCompanionTrustedDeviceRecord: Codable, Equatable, Identifiable, S
40
43
  var fingerprint: String
41
44
  var platform: String
42
45
  var appVersion: String?
46
+ var capabilities: [String]?
43
47
  var pairedAt: Date
44
48
  var lastSeenAt: Date
45
49
 
@@ -48,10 +52,15 @@ struct LatticesCompanionTrustedDeviceRecord: Codable, Equatable, Identifiable, S
48
52
  id: id,
49
53
  name: name,
50
54
  fingerprint: fingerprint,
55
+ capabilities: effectiveCapabilities,
51
56
  pairedAt: pairedAt,
52
57
  lastSeenAt: lastSeenAt
53
58
  )
54
59
  }
60
+
61
+ var effectiveCapabilities: [String] {
62
+ capabilities ?? DeckBridgeCapability.defaultCompanionCapabilities
63
+ }
55
64
  }
56
65
 
57
66
  struct AuthorizedBridgeRequest {
@@ -117,9 +126,15 @@ final class LatticesCompanionSecurityCoordinator {
117
126
  persistTrustedDevices()
118
127
  }
119
128
 
129
+ func revokeTrustedDevice(id: String) {
130
+ trustedDevices.removeValue(forKey: id)
131
+ persistTrustedDevices()
132
+ }
133
+
120
134
  func handlePairingRequest(_ request: DeckPairingRequest) -> DeckPairingResponse {
121
135
  let diag = DiagnosticLog.shared
122
136
  diag.info("CompanionPairing: request device=\(request.deviceName) id=\(request.deviceID)")
137
+ let grantedCapabilities = Self.grantedCapabilities(for: request.requestedCapabilities)
123
138
 
124
139
  guard
125
140
  request.deviceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
@@ -134,12 +149,14 @@ final class LatticesCompanionSecurityCoordinator {
134
149
  bridgeFingerprint: bridgeFingerprint,
135
150
  requestSigningRequired: true,
136
151
  payloadEncryptionRequired: true,
152
+ grantedCapabilities: [],
137
153
  detail: LatticesCompanionSecurityError.invalidDeviceKey.localizedDescription
138
154
  )
139
155
  }
140
156
 
141
157
  if var existing = trustedDevices[request.deviceID], existing.publicKey == request.devicePublicKey {
142
158
  existing.lastSeenAt = Date()
159
+ existing.capabilities = grantedCapabilities.isEmpty ? existing.effectiveCapabilities : grantedCapabilities
143
160
  trustedDevices[request.deviceID] = existing
144
161
  persistTrustedDevices()
145
162
  diag.success("CompanionPairing: device already trusted id=\(request.deviceID)")
@@ -150,6 +167,7 @@ final class LatticesCompanionSecurityCoordinator {
150
167
  bridgeFingerprint: bridgeFingerprint,
151
168
  requestSigningRequired: true,
152
169
  payloadEncryptionRequired: true,
170
+ grantedCapabilities: existing.effectiveCapabilities,
153
171
  detail: "This device is already trusted on the Mac."
154
172
  )
155
173
  }
@@ -164,6 +182,7 @@ final class LatticesCompanionSecurityCoordinator {
164
182
  bridgeFingerprint: bridgeFingerprint,
165
183
  requestSigningRequired: true,
166
184
  payloadEncryptionRequired: true,
185
+ grantedCapabilities: [],
167
186
  detail: "Pairing was denied on the Mac."
168
187
  )
169
188
  }
@@ -176,6 +195,7 @@ final class LatticesCompanionSecurityCoordinator {
176
195
  fingerprint: Self.fingerprint(forPublicKeyBase64: request.devicePublicKey),
177
196
  platform: request.platform,
178
197
  appVersion: request.appVersion,
198
+ capabilities: grantedCapabilities,
179
199
  pairedAt: now,
180
200
  lastSeenAt: now
181
201
  )
@@ -189,6 +209,7 @@ final class LatticesCompanionSecurityCoordinator {
189
209
  bridgeFingerprint: bridgeFingerprint,
190
210
  requestSigningRequired: true,
191
211
  payloadEncryptionRequired: true,
212
+ grantedCapabilities: grantedCapabilities,
192
213
  detail: "Trusted and ready for encrypted bridge requests."
193
214
  )
194
215
  }
@@ -241,6 +262,12 @@ final class LatticesCompanionSecurityCoordinator {
241
262
  )
242
263
  }
243
264
 
265
+ func requireCapability(_ capability: String, for auth: AuthorizedBridgeRequest) throws {
266
+ guard auth.device.effectiveCapabilities.contains(capability) else {
267
+ throw LatticesCompanionSecurityError.insufficientCapability(capability)
268
+ }
269
+ }
270
+
244
271
  func decodeProtectedBody<T: Decodable>(
245
272
  _ type: T.Type,
246
273
  body: Data,
@@ -315,6 +342,14 @@ private extension LatticesCompanionSecurityCoordinator {
315
342
  return compact.chunked(into: 4).joined(separator: "-")
316
343
  }
317
344
 
345
+ static func grantedCapabilities(for requested: [String]) -> [String] {
346
+ let supported = Set(DeckBridgeCapability.defaultCompanionCapabilities)
347
+ let requested = requested.isEmpty ? supported : Set(requested)
348
+ return requested
349
+ .intersection(supported)
350
+ .sorted()
351
+ }
352
+
318
353
  func persistTrustedDevices() {
319
354
  let sorted = trustedDevices.values.sorted { lhs, rhs in
320
355
  lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
@@ -13,7 +13,7 @@ final class LatticesCompanionTrackpadController {
13
13
  isEnabled: false,
14
14
  isAvailable: false,
15
15
  statusTitle: "Trackpad Off",
16
- statusDetail: "Enable the companion trackpad from the Mac Shortcuts settings.",
16
+ statusDetail: "Enable the companion trackpad from the Mac Companion settings.",
17
17
  pointerScale: 1.6,
18
18
  scrollScale: 1.0,
19
19
  supportsDragLock: true
@@ -0,0 +1,69 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+
4
+ enum KeyboardRemapKey: String, Codable, Equatable {
5
+ case capsLock = "caps_lock"
6
+
7
+ var keyCode: Int64 {
8
+ switch self {
9
+ case .capsLock: return 57
10
+ }
11
+ }
12
+
13
+ var displayLabel: String {
14
+ switch self {
15
+ case .capsLock: return "Caps Lock"
16
+ }
17
+ }
18
+ }
19
+
20
+ enum KeyboardRemapAction: String, Codable, Equatable {
21
+ case escape
22
+ case hyper
23
+
24
+ var displayLabel: String {
25
+ switch self {
26
+ case .escape: return "Escape"
27
+ case .hyper: return "Hyper"
28
+ }
29
+ }
30
+ }
31
+
32
+ struct KeyboardRemapRule: Codable, Equatable, Identifiable {
33
+ var id: String
34
+ var enabled: Bool
35
+ var from: KeyboardRemapKey
36
+ var toIfHeld: KeyboardRemapAction
37
+ var toIfAlone: KeyboardRemapAction?
38
+
39
+ var summaryLine: String {
40
+ let held = "hold \(from.displayLabel) -> \(toIfHeld.displayLabel)"
41
+ guard let alone = toIfAlone else { return held }
42
+ return "\(held), tap -> \(alone.displayLabel)"
43
+ }
44
+ }
45
+
46
+ struct KeyboardRemapConfig: Codable, Equatable {
47
+ var rules: [KeyboardRemapRule]
48
+
49
+ static let defaults = KeyboardRemapConfig(
50
+ rules: [
51
+ KeyboardRemapRule(
52
+ id: "caps_lock_hyper_escape",
53
+ enabled: true,
54
+ from: .capsLock,
55
+ toIfHeld: .hyper,
56
+ toIfAlone: .escape
57
+ )
58
+ ]
59
+ )
60
+ }
61
+
62
+ extension CGEventFlags {
63
+ static let latticesHyper: CGEventFlags = [
64
+ .maskCommand,
65
+ .maskControl,
66
+ .maskAlternate,
67
+ .maskShift,
68
+ ]
69
+ }
@@ -0,0 +1,184 @@
1
+ import AppKit
2
+ import Combine
3
+ import CoreGraphics
4
+
5
+ final class KeyboardRemapController {
6
+ static let shared = KeyboardRemapController()
7
+
8
+ private static let syntheticMarker: Int64 = 0x4C4B524D
9
+
10
+ private var eventTap: CFMachPort?
11
+ private var runLoopSource: CFRunLoopSource?
12
+ private var subscriptions: Set<AnyCancellable> = []
13
+ private var installedObservers = false
14
+ private var capsLayerActive = false
15
+ private var capsUsedAsModifier = false
16
+
17
+ private init() {}
18
+
19
+ func start() {
20
+ installObserversIfNeeded()
21
+ refresh()
22
+ }
23
+
24
+ func stop() {
25
+ removeEventTap()
26
+ capsLayerActive = false
27
+ capsUsedAsModifier = false
28
+ }
29
+
30
+ private func installObserversIfNeeded() {
31
+ guard !installedObservers else { return }
32
+ installedObservers = true
33
+
34
+ Preferences.shared.$keyboardRemapsEnabled
35
+ .receive(on: RunLoop.main)
36
+ .sink { [weak self] _ in self?.refresh() }
37
+ .store(in: &subscriptions)
38
+
39
+ PermissionChecker.shared.$accessibility
40
+ .receive(on: RunLoop.main)
41
+ .sink { [weak self] _ in self?.refresh() }
42
+ .store(in: &subscriptions)
43
+ }
44
+
45
+ private func refresh() {
46
+ guard Preferences.shared.keyboardRemapsEnabled,
47
+ PermissionChecker.shared.accessibility else {
48
+ removeEventTap()
49
+ return
50
+ }
51
+
52
+ KeyboardRemapStore.shared.ensureConfigFile()
53
+ if eventTap == nil {
54
+ installEventTap()
55
+ } else if let eventTap {
56
+ CGEvent.tapEnable(tap: eventTap, enable: true)
57
+ }
58
+ }
59
+
60
+ private func installEventTap() {
61
+ var mask = CGEventMask(0)
62
+ mask |= CGEventMask(1) << CGEventType.keyDown.rawValue
63
+ mask |= CGEventMask(1) << CGEventType.keyUp.rawValue
64
+ mask |= CGEventMask(1) << CGEventType.flagsChanged.rawValue
65
+
66
+ let tap = CGEvent.tapCreate(
67
+ tap: .cgSessionEventTap,
68
+ place: .headInsertEventTap,
69
+ options: .defaultTap,
70
+ eventsOfInterest: mask,
71
+ callback: Self.eventTapCallback,
72
+ userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
73
+ )
74
+
75
+ guard let tap else {
76
+ DiagnosticLog.shared.warn("KeyboardRemap: failed to install keyboard event tap")
77
+ return
78
+ }
79
+
80
+ let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
81
+ eventTap = tap
82
+ runLoopSource = source
83
+
84
+ if let source {
85
+ CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
86
+ }
87
+ CGEvent.tapEnable(tap: tap, enable: true)
88
+ DiagnosticLog.shared.info("KeyboardRemap: keyboard event tap installed")
89
+ }
90
+
91
+ private func removeEventTap() {
92
+ if let source = runLoopSource {
93
+ CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
94
+ }
95
+ runLoopSource = nil
96
+ if let tap = eventTap {
97
+ CFMachPortInvalidate(tap)
98
+ }
99
+ eventTap = nil
100
+ }
101
+
102
+ private static let eventTapCallback: CGEventTapCallBack = { _, type, event, userInfo in
103
+ guard let userInfo else { return Unmanaged.passUnretained(event) }
104
+ let controller = Unmanaged<KeyboardRemapController>.fromOpaque(userInfo).takeUnretainedValue()
105
+ return controller.handleEvent(type: type, event: event)
106
+ }
107
+
108
+ private func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
109
+ if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
110
+ if let eventTap {
111
+ CGEvent.tapEnable(tap: eventTap, enable: true)
112
+ }
113
+ return Unmanaged.passUnretained(event)
114
+ }
115
+
116
+ if event.getIntegerValueField(.eventSourceUserData) == Self.syntheticMarker {
117
+ return Unmanaged.passUnretained(event)
118
+ }
119
+
120
+ KeyboardRemapStore.shared.reloadIfNeeded()
121
+ guard let rule = KeyboardRemapStore.shared.capsLockRule,
122
+ rule.toIfHeld == .hyper else {
123
+ return Unmanaged.passUnretained(event)
124
+ }
125
+
126
+ let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
127
+ if type == .flagsChanged, keyCode == rule.from.keyCode {
128
+ return handleCapsLockFlagsChanged(event, rule: rule)
129
+ }
130
+
131
+ guard capsLayerActive else {
132
+ return Unmanaged.passUnretained(event)
133
+ }
134
+
135
+ switch type {
136
+ case .keyDown:
137
+ capsUsedAsModifier = true
138
+ event.flags = normalizedFlags(event.flags).union(.latticesHyper)
139
+ return Unmanaged.passUnretained(event)
140
+ case .keyUp:
141
+ event.flags = normalizedFlags(event.flags).union(.latticesHyper)
142
+ return Unmanaged.passUnretained(event)
143
+ default:
144
+ return Unmanaged.passUnretained(event)
145
+ }
146
+ }
147
+
148
+ private func handleCapsLockFlagsChanged(_ event: CGEvent, rule: KeyboardRemapRule) -> Unmanaged<CGEvent>? {
149
+ let isDown = event.flags.contains(.maskAlphaShift)
150
+ if isDown {
151
+ capsLayerActive = true
152
+ capsUsedAsModifier = false
153
+ DiagnosticLog.shared.info("KeyboardRemap: Caps Lock layer active")
154
+ } else {
155
+ let shouldTap = capsLayerActive && !capsUsedAsModifier && rule.toIfAlone == .escape
156
+ capsLayerActive = false
157
+ capsUsedAsModifier = false
158
+ if shouldTap {
159
+ postKeyTap(keyCode: 53)
160
+ }
161
+ DiagnosticLog.shared.info("KeyboardRemap: Caps Lock layer inactive")
162
+ }
163
+
164
+ return nil
165
+ }
166
+
167
+ private func normalizedFlags(_ flags: CGEventFlags) -> CGEventFlags {
168
+ var normalized = flags
169
+ normalized.remove(.maskAlphaShift)
170
+ return normalized
171
+ }
172
+
173
+ private func postKeyTap(keyCode: CGKeyCode) {
174
+ guard let source = CGEventSource(stateID: .combinedSessionState),
175
+ let down = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true),
176
+ let up = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) else {
177
+ return
178
+ }
179
+ down.setIntegerValueField(.eventSourceUserData, value: Self.syntheticMarker)
180
+ up.setIntegerValueField(.eventSourceUserData, value: Self.syntheticMarker)
181
+ down.post(tap: .cghidEventTap)
182
+ up.post(tap: .cghidEventTap)
183
+ }
184
+ }
@@ -0,0 +1,84 @@
1
+ import AppKit
2
+ import Combine
3
+ import Foundation
4
+
5
+ final class KeyboardRemapStore: ObservableObject {
6
+ static let shared = KeyboardRemapStore()
7
+
8
+ @Published private(set) var config: KeyboardRemapConfig
9
+
10
+ let configURL: URL
11
+ private var lastLoadedModifiedDate: Date?
12
+
13
+ private init() {
14
+ let dir = FileManager.default.homeDirectoryForCurrentUser
15
+ .appendingPathComponent(".lattices")
16
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
17
+ self.configURL = dir.appendingPathComponent("keyboard-remaps.json")
18
+ self.config = .defaults
19
+ ensureConfigFile()
20
+ reload()
21
+ }
22
+
23
+ var enabledRules: [KeyboardRemapRule] {
24
+ config.rules.filter(\.enabled)
25
+ }
26
+
27
+ var summaryLines: [String] {
28
+ enabledRules.map(\.summaryLine)
29
+ }
30
+
31
+ var capsLockRule: KeyboardRemapRule? {
32
+ enabledRules.first { $0.from == .capsLock }
33
+ }
34
+
35
+ func ensureConfigFile() {
36
+ guard !FileManager.default.fileExists(atPath: configURL.path) else { return }
37
+ write(config: .defaults)
38
+ }
39
+
40
+ func reload() {
41
+ guard let data = FileManager.default.contents(atPath: configURL.path) else {
42
+ config = .defaults
43
+ return
44
+ }
45
+
46
+ do {
47
+ config = try JSONDecoder().decode(KeyboardRemapConfig.self, from: data)
48
+ lastLoadedModifiedDate = modifiedDate()
49
+ } catch {
50
+ DiagnosticLog.shared.error("KeyboardRemapStore: failed to decode keyboard-remaps.json - \(error.localizedDescription)")
51
+ config = .defaults
52
+ }
53
+ }
54
+
55
+ func reloadIfNeeded() {
56
+ let currentModifiedDate = modifiedDate()
57
+ guard currentModifiedDate != lastLoadedModifiedDate else { return }
58
+ reload()
59
+ }
60
+
61
+ func restoreDefaults() {
62
+ write(config: .defaults)
63
+ reload()
64
+ DiagnosticLog.shared.info("Keyboard remaps restored to defaults")
65
+ }
66
+
67
+ func openConfiguration() {
68
+ ensureConfigFile()
69
+ NSWorkspace.shared.open(configURL)
70
+ }
71
+
72
+ private func write(config: KeyboardRemapConfig) {
73
+ let encoder = JSONEncoder()
74
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
75
+ guard let data = try? encoder.encode(config) else { return }
76
+ try? data.write(to: configURL, options: .atomic)
77
+ lastLoadedModifiedDate = modifiedDate()
78
+ }
79
+
80
+ private func modifiedDate() -> Date? {
81
+ let attrs = try? FileManager.default.attributesOfItem(atPath: configURL.path)
82
+ return attrs?[.modificationDate] as? Date
83
+ }
84
+ }
@@ -13,7 +13,7 @@ enum SessionManager {
13
13
  }
14
14
  terminal.focusOrAttach(session: project.sessionName)
15
15
  } else {
16
- terminal.launch(command: latticesPath, in: project.path)
16
+ terminal.launch(command: "\(latticesPath) start", in: project.path)
17
17
  }
18
18
  }
19
19
 
@@ -555,12 +555,12 @@ class WorkspaceManager: ObservableObject {
555
555
  let label = tab.label ?? (tab.path as NSString).lastPathComponent
556
556
  DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.4) {
557
557
  if i == 0 {
558
- terminal.launch(command: "/opt/homebrew/bin/lattices", in: tab.path)
558
+ terminal.launch(command: "/opt/homebrew/bin/lattices start", in: tab.path)
559
559
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
560
560
  terminal.nameTab(label)
561
561
  }
562
562
  } else {
563
- terminal.launchTab(command: "/opt/homebrew/bin/lattices", in: tab.path, tabName: label)
563
+ terminal.launchTab(command: "/opt/homebrew/bin/lattices start", in: tab.path, tabName: label)
564
564
  }
565
565
  }
566
566
  }
@@ -851,7 +851,7 @@ class WorkspaceManager: ObservableObject {
851
851
  diag.finish(t)
852
852
  } else {
853
853
  diag.info(" launch (direct): \(sessionName)")
854
- terminal.launch(command: "/opt/homebrew/bin/lattices", in: path)
854
+ terminal.launch(command: "/opt/homebrew/bin/lattices start", in: path)
855
855
  }
856
856
  launchQueue.append((sessionName, position, lpScreen, {}))
857
857
  } else {
@@ -152,6 +152,17 @@ function writeInfoPlist(): void {
152
152
  <string>AppIcon</string>
153
153
  <key>CFBundlePackageType</key>
154
154
  <string>APPL</string>
155
+ <key>CFBundleURLTypes</key>
156
+ <array>
157
+ <dict>
158
+ <key>CFBundleURLName</key>
159
+ <string>com.arach.lattices</string>
160
+ <key>CFBundleURLSchemes</key>
161
+ <array>
162
+ <string>lattices</string>
163
+ </array>
164
+ </dict>
165
+ </array>
155
166
  <key>CFBundleVersion</key>
156
167
  <string>${version}</string>
157
168
  <key>CFBundleShortVersionString</key>
package/bin/lattices-dev CHANGED
@@ -78,6 +78,17 @@ write_info_plist() {
78
78
  <string>AppIcon</string>
79
79
  <key>CFBundlePackageType</key>
80
80
  <string>APPL</string>
81
+ <key>CFBundleURLTypes</key>
82
+ <array>
83
+ <dict>
84
+ <key>CFBundleURLName</key>
85
+ <string>com.arach.lattices</string>
86
+ <key>CFBundleURLSchemes</key>
87
+ <array>
88
+ <string>lattices</string>
89
+ </array>
90
+ </dict>
91
+ </array>
81
92
  <key>CFBundleVersion</key>
82
93
  <string>$VERSION</string>
83
94
  <key>CFBundleShortVersionString</key>