@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
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|
|
@@ -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 {
|
package/bin/lattices-app.ts
CHANGED
|
@@ -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>
|