@lattices/cli 0.4.1 → 0.4.5
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 +3 -0
- package/app/Info.plist +2 -2
- package/app/Lattices.app/Contents/Info.plist +2 -2
- package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/app/Package.swift +6 -0
- package/app/Sources/ActionRow.swift +43 -26
- package/app/Sources/App.swift +10 -0
- package/app/Sources/AppDelegate.swift +91 -30
- package/app/Sources/AppShellView.swift +2 -0
- package/app/Sources/AppTypeClassifier.swift +36 -0
- package/app/Sources/AppUpdater.swift +92 -0
- package/app/Sources/CheatSheetHUD.swift +1 -0
- package/app/Sources/CliActionLauncher.swift +50 -0
- package/app/Sources/CommandModeView.swift +4 -24
- package/app/Sources/CompanionActivityLog.swift +70 -0
- package/app/Sources/CompanionKeyboardController.swift +141 -0
- package/app/Sources/DesktopModel.swift +4 -0
- package/app/Sources/HandsOffSession.swift +53 -16
- package/app/Sources/HomeDashboardView.swift +18 -10
- package/app/Sources/HotkeyStore.swift +8 -5
- package/app/Sources/IntentEngine.swift +7 -1
- package/app/Sources/LatticesApi.swift +125 -4
- package/app/Sources/LatticesCompanionBridgeServer.swift +438 -0
- package/app/Sources/LatticesCompanionCockpit.swift +555 -0
- package/app/Sources/LatticesCompanionSecurityCoordinator.swift +594 -0
- package/app/Sources/LatticesCompanionTrackpadController.swift +204 -0
- package/app/Sources/LatticesDeckHost.swift +1463 -0
- package/app/Sources/LatticesRuntime.swift +61 -0
- package/app/Sources/MainView.swift +398 -186
- package/app/Sources/MouseFinder.swift +335 -30
- package/app/Sources/MouseGestureConfig.swift +364 -0
- package/app/Sources/MouseGestureController.swift +1203 -0
- package/app/Sources/MouseInputDeviceStore.swift +98 -0
- package/app/Sources/MouseInputEventViewer.swift +272 -0
- package/app/Sources/MouseShortcutStore.swift +107 -0
- package/app/Sources/OmniSearchView.swift +136 -2
- package/app/Sources/OmniSearchWindow.swift +65 -5
- package/app/Sources/OnboardingView.swift +30 -16
- package/app/Sources/PaletteCommand.swift +26 -6
- package/app/Sources/PermissionChecker.swift +76 -2
- package/app/Sources/PiAuthNextStepCard.swift +148 -0
- package/app/Sources/PiAuthPromptCard.swift +90 -0
- package/app/Sources/PiChatDock.swift +137 -74
- package/app/Sources/PiChatSession.swift +608 -108
- package/app/Sources/PiInstallCallout.swift +86 -0
- package/app/Sources/PiProviderSetupCallout.swift +99 -0
- package/app/Sources/PiWorkspaceView.swift +174 -77
- package/app/Sources/Preferences.swift +78 -0
- package/app/Sources/ScreenMapState.swift +91 -31
- package/app/Sources/ScreenMapView.swift +510 -524
- package/app/Sources/ScreenMapWindowController.swift +12 -4
- package/app/Sources/SettingsView.swift +869 -152
- package/app/Sources/SystemTelemetryMonitor.swift +273 -0
- package/app/Sources/VoiceCommandWindow.swift +23 -2
- package/app/Sources/WindowDragSnapController.swift +628 -0
- package/app/Sources/WindowTiler.swift +328 -65
- package/app/Sources/WorkspaceManager.swift +288 -0
- package/bin/assistant-intelligence.ts +874 -0
- package/bin/handsoff-infer.ts +16 -209
- package/bin/handsoff-worker.ts +45 -258
- package/bin/lattices-app.ts +65 -1
- package/bin/lattices-dev +4 -0
- package/bin/lattices.ts +125 -14
- package/docs/agents.md +14 -0
- package/docs/api.md +55 -0
- package/docs/app.md +3 -0
- package/docs/companion-deck.md +180 -0
- package/docs/config.md +25 -0
- package/docs/tiling-reference.md +55 -0
- package/docs/voice-error-model.md +73 -0
- package/package.json +4 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import AppKit
|
|
2
2
|
import ApplicationServices
|
|
3
|
+
import DeckKit
|
|
3
4
|
import Foundation
|
|
4
5
|
|
|
5
6
|
// MARK: - Registry Types
|
|
@@ -197,6 +198,7 @@ final class LatticesApi {
|
|
|
197
198
|
api.model(ApiModel(name: "Space", fields: [
|
|
198
199
|
Field(name: "id", type: "int", required: true, description: "Space ID"),
|
|
199
200
|
Field(name: "index", type: "int", required: true, description: "Space index"),
|
|
201
|
+
Field(name: "name", type: "string", required: true, description: "Lattices display name for the space"),
|
|
200
202
|
Field(name: "display", type: "int", required: true, description: "Display index"),
|
|
201
203
|
Field(name: "isCurrent", type: "bool", required: true, description: "Whether this is the active space"),
|
|
202
204
|
]))
|
|
@@ -736,6 +738,7 @@ final class LatticesApi {
|
|
|
736
738
|
.object([
|
|
737
739
|
"id": .int(space.id),
|
|
738
740
|
"index": .int(space.index),
|
|
741
|
+
"name": .string(Self.defaultSpaceName(for: space.index)),
|
|
739
742
|
"display": .int(space.display),
|
|
740
743
|
"isCurrent": .bool(space.isCurrent)
|
|
741
744
|
])
|
|
@@ -773,6 +776,45 @@ final class LatticesApi {
|
|
|
773
776
|
}
|
|
774
777
|
))
|
|
775
778
|
|
|
779
|
+
api.register(Endpoint(
|
|
780
|
+
method: "deck.manifest",
|
|
781
|
+
description: "Get the shared companion deck manifest exposed by the macOS app",
|
|
782
|
+
access: .read,
|
|
783
|
+
params: [],
|
|
784
|
+
returns: .custom("DeckKit manifest for the Lattices companion surface"),
|
|
785
|
+
handler: { _ in
|
|
786
|
+
try Self.encodeDeckValue(LatticesDeckHost.shared.manifestSync())
|
|
787
|
+
}
|
|
788
|
+
))
|
|
789
|
+
|
|
790
|
+
api.register(Endpoint(
|
|
791
|
+
method: "deck.snapshot",
|
|
792
|
+
description: "Get the current companion deck runtime snapshot",
|
|
793
|
+
access: .read,
|
|
794
|
+
params: [],
|
|
795
|
+
returns: .custom("DeckKit runtime snapshot with voice, layout, switcher, and history state"),
|
|
796
|
+
handler: { _ in
|
|
797
|
+
try Self.encodeDeckValue(LatticesDeckHost.shared.runtimeSnapshotSync())
|
|
798
|
+
}
|
|
799
|
+
))
|
|
800
|
+
|
|
801
|
+
api.register(Endpoint(
|
|
802
|
+
method: "deck.perform",
|
|
803
|
+
description: "Perform a companion deck action and return the updated runtime snapshot",
|
|
804
|
+
access: .mutate,
|
|
805
|
+
params: [
|
|
806
|
+
Param(name: "pageID", type: "string", required: false, description: "Deck page ID"),
|
|
807
|
+
Param(name: "actionID", type: "string", required: true, description: "Deck action identifier"),
|
|
808
|
+
Param(name: "payload", type: "object", required: false, description: "Deck action payload"),
|
|
809
|
+
],
|
|
810
|
+
returns: .custom("DeckKit action result"),
|
|
811
|
+
handler: { params in
|
|
812
|
+
let request = try Self.decodeDeckActionRequest(from: params)
|
|
813
|
+
let result = try LatticesDeckHost.shared.performSync(request)
|
|
814
|
+
return try Self.encodeDeckValue(result)
|
|
815
|
+
}
|
|
816
|
+
))
|
|
817
|
+
|
|
776
818
|
api.register(Endpoint(
|
|
777
819
|
method: "daemon.status",
|
|
778
820
|
description: "Get daemon status including uptime and counts",
|
|
@@ -1339,10 +1381,11 @@ final class LatticesApi {
|
|
|
1339
1381
|
|
|
1340
1382
|
api.register(Endpoint(
|
|
1341
1383
|
method: "layout.distribute",
|
|
1342
|
-
description: "Distribute windows evenly in a grid, optionally filtered by app and constrained to a screen region",
|
|
1384
|
+
description: "Distribute windows evenly in a grid, optionally filtered by app or type and constrained to a screen region",
|
|
1343
1385
|
access: .mutate,
|
|
1344
1386
|
params: [
|
|
1345
1387
|
Param(name: "app", type: "string", required: false, description: "Filter to windows of this app (e.g. 'iTerm2')"),
|
|
1388
|
+
Param(name: "type", type: "string", required: false, description: "Filter to an app type (e.g. 'terminal', 'browser', 'editor')"),
|
|
1346
1389
|
Param(name: "region", type: "string", required: false, description: "Constrain grid to a screen region (e.g. 'right', 'left', 'top-right'). Uses tile position names."),
|
|
1347
1390
|
],
|
|
1348
1391
|
returns: .ok,
|
|
@@ -1351,9 +1394,11 @@ final class LatticesApi {
|
|
|
1351
1394
|
if case .object(let obj) = params {
|
|
1352
1395
|
dict = obj
|
|
1353
1396
|
}
|
|
1354
|
-
//
|
|
1397
|
+
// Explicit filters select the matching scope automatically.
|
|
1355
1398
|
if dict["app"] != nil && dict["scope"] == nil {
|
|
1356
1399
|
dict["scope"] = .string("app")
|
|
1400
|
+
} else if dict["type"] != nil && dict["scope"] == nil {
|
|
1401
|
+
dict["scope"] = .string("type")
|
|
1357
1402
|
} else {
|
|
1358
1403
|
dict["scope"] = dict["scope"] ?? .string("visible")
|
|
1359
1404
|
}
|
|
@@ -1367,9 +1412,10 @@ final class LatticesApi {
|
|
|
1367
1412
|
description: "Optimize a set of windows using an explicit scope and strategy",
|
|
1368
1413
|
access: .mutate,
|
|
1369
1414
|
params: [
|
|
1370
|
-
Param(name: "scope", type: "string", required: false, description: "Optimization scope: visible, active-app, app, or selection"),
|
|
1415
|
+
Param(name: "scope", type: "string", required: false, description: "Optimization scope: visible, active-app, active-type, app, type, or selection"),
|
|
1371
1416
|
Param(name: "strategy", type: "string", required: false, description: "Optimization strategy: balanced or mosaic"),
|
|
1372
1417
|
Param(name: "app", type: "string", required: false, description: "App name for app-scoped optimization"),
|
|
1418
|
+
Param(name: "type", type: "string", required: false, description: "App type for type-scoped optimization"),
|
|
1373
1419
|
Param(name: "title", type: "string", required: false, description: "Optional title substring for app-scoped optimization"),
|
|
1374
1420
|
Param(name: "windowIds", type: "[uint32]", required: false, description: "Explicit window selection for selection scope"),
|
|
1375
1421
|
],
|
|
@@ -1872,6 +1918,27 @@ final class LatticesApi {
|
|
|
1872
1918
|
}
|
|
1873
1919
|
|
|
1874
1920
|
private extension LatticesApi {
|
|
1921
|
+
static func decodeDeckActionRequest(from json: JSON?) throws -> DeckActionRequest {
|
|
1922
|
+
guard let json else {
|
|
1923
|
+
throw RouterError.missingParam("actionID")
|
|
1924
|
+
}
|
|
1925
|
+
guard case .object(var object) = json else {
|
|
1926
|
+
throw RouterError.custom("Invalid deck action request: params must be an object")
|
|
1927
|
+
}
|
|
1928
|
+
object["payload"] = object["payload"] ?? .object([:])
|
|
1929
|
+
let data = try JSONEncoder().encode(JSON.object(object))
|
|
1930
|
+
do {
|
|
1931
|
+
return try JSONDecoder().decode(DeckActionRequest.self, from: data)
|
|
1932
|
+
} catch {
|
|
1933
|
+
throw RouterError.custom("Invalid deck action request: \(error.localizedDescription)")
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
static func encodeDeckValue<T: Encodable>(_ value: T) throws -> JSON {
|
|
1938
|
+
let data = try JSONEncoder().encode(value)
|
|
1939
|
+
return try JSONDecoder().decode(JSON.self, from: data)
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1875
1942
|
static func parsePlacement(from json: JSON?) -> PlacementSpec? {
|
|
1876
1943
|
PlacementSpec(json: json)
|
|
1877
1944
|
}
|
|
@@ -2070,6 +2137,19 @@ private extension LatticesApi {
|
|
|
2070
2137
|
])
|
|
2071
2138
|
}
|
|
2072
2139
|
|
|
2140
|
+
static func defaultSpaceName(for index: Int) -> String {
|
|
2141
|
+
if let layers = WorkspaceManager.shared.config?.layers,
|
|
2142
|
+
layers.indices.contains(index - 1) {
|
|
2143
|
+
return layers[index - 1].label
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
let defaults = ["main", "code", "chat", "review", "media", "notes", "ops", "admin", "scratch"]
|
|
2147
|
+
if defaults.indices.contains(index - 1) {
|
|
2148
|
+
return defaults[index - 1]
|
|
2149
|
+
}
|
|
2150
|
+
return "space \(index)"
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2073
2153
|
static func executeSpaceOptimization(params: JSON?) throws -> JSON {
|
|
2074
2154
|
let scope = try parseOptimizationScope(from: params)
|
|
2075
2155
|
let strategy = try parseOptimizationStrategy(params?["strategy"]?.stringValue)
|
|
@@ -2135,10 +2215,15 @@ private extension LatticesApi {
|
|
|
2135
2215
|
if params?["app"] != nil {
|
|
2136
2216
|
return "app"
|
|
2137
2217
|
}
|
|
2218
|
+
if params?["type"] != nil {
|
|
2219
|
+
return "type"
|
|
2220
|
+
}
|
|
2138
2221
|
|
|
2139
2222
|
let scope = normalizeToken(params?["scope"]?.stringValue ?? "visible")
|
|
2140
2223
|
switch scope {
|
|
2141
|
-
case "visible", "selection", "app", "
|
|
2224
|
+
case "visible", "selection", "app", "type",
|
|
2225
|
+
"active-app", "frontmost-app", "current-app",
|
|
2226
|
+
"active-type", "frontmost-type", "current-type":
|
|
2142
2227
|
return scope
|
|
2143
2228
|
default:
|
|
2144
2229
|
throw RouterError.custom("Unsupported optimization scope: \(params?["scope"]?.stringValue ?? scope)")
|
|
@@ -2179,6 +2264,21 @@ private extension LatticesApi {
|
|
|
2179
2264
|
(titleFilter == nil || $0.title.localizedCaseInsensitiveContains(titleFilter!))
|
|
2180
2265
|
})
|
|
2181
2266
|
|
|
2267
|
+
case "type":
|
|
2268
|
+
guard let typeName = params?["type"]?.stringValue,
|
|
2269
|
+
let appType = parseOptimizationAppType(typeName) else {
|
|
2270
|
+
trace.append(.string("missing or unknown type for type scope"))
|
|
2271
|
+
return []
|
|
2272
|
+
}
|
|
2273
|
+
trace.append(.string("filtered by type \(appType.rawValue)"))
|
|
2274
|
+
if let titleFilter {
|
|
2275
|
+
trace.append(.string("title contains \(titleFilter)"))
|
|
2276
|
+
}
|
|
2277
|
+
return dedupeWindows(visible.filter {
|
|
2278
|
+
AppTypeClassifier.matches($0.app, type: appType) &&
|
|
2279
|
+
(titleFilter == nil || $0.title.localizedCaseInsensitiveContains(titleFilter!))
|
|
2280
|
+
})
|
|
2281
|
+
|
|
2182
2282
|
case "active-app", "frontmost-app", "current-app":
|
|
2183
2283
|
let activeApp = params?["app"]?.stringValue ?? frontmostOptimizableApp()
|
|
2184
2284
|
guard let activeApp else {
|
|
@@ -2194,12 +2294,33 @@ private extension LatticesApi {
|
|
|
2194
2294
|
(titleFilter == nil || $0.title.localizedCaseInsensitiveContains(titleFilter!))
|
|
2195
2295
|
})
|
|
2196
2296
|
|
|
2297
|
+
case "active-type", "frontmost-type", "current-type":
|
|
2298
|
+
let activeApp = params?["app"]?.stringValue ?? frontmostOptimizableApp()
|
|
2299
|
+
guard let activeApp else {
|
|
2300
|
+
trace.append(.string("no active app available"))
|
|
2301
|
+
return []
|
|
2302
|
+
}
|
|
2303
|
+
let grouping = AppTypeClassifier.grouping(for: activeApp)
|
|
2304
|
+
trace.append(.string("resolved active type \(grouping.label) from \(activeApp)"))
|
|
2305
|
+
if let titleFilter {
|
|
2306
|
+
trace.append(.string("title contains \(titleFilter)"))
|
|
2307
|
+
}
|
|
2308
|
+
return dedupeWindows(visible.filter {
|
|
2309
|
+
AppTypeClassifier.matches($0.app, grouping: grouping) &&
|
|
2310
|
+
(titleFilter == nil || $0.title.localizedCaseInsensitiveContains(titleFilter!))
|
|
2311
|
+
})
|
|
2312
|
+
|
|
2197
2313
|
default:
|
|
2198
2314
|
trace.append(.string("using visible window scope"))
|
|
2199
2315
|
return dedupeWindows(visible)
|
|
2200
2316
|
}
|
|
2201
2317
|
}
|
|
2202
2318
|
|
|
2319
|
+
static func parseOptimizationAppType(_ raw: String) -> AppType? {
|
|
2320
|
+
let normalized = normalizeToken(raw)
|
|
2321
|
+
return AppType.allCases.first { $0.rawValue == normalized }
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2203
2324
|
static func selectedWindowIds(from json: JSON?) -> [UInt32] {
|
|
2204
2325
|
guard case .array(let values) = json else { return [] }
|
|
2205
2326
|
return values.compactMap(\.uint32Value)
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import Darwin
|
|
2
|
+
import DeckKit
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
final class LatticesCompanionBridgeServer: NSObject {
|
|
6
|
+
static let shared = LatticesCompanionBridgeServer()
|
|
7
|
+
|
|
8
|
+
static let bonjourType = "_lattices-companion._tcp."
|
|
9
|
+
static let defaultPort: UInt16 = 5287
|
|
10
|
+
|
|
11
|
+
private let queue = DispatchQueue(label: "lattices.companion.bridge", qos: .userInitiated)
|
|
12
|
+
private let encoder = JSONEncoder()
|
|
13
|
+
private let decoder = JSONDecoder()
|
|
14
|
+
|
|
15
|
+
private var serverFd: Int32 = -1
|
|
16
|
+
private var acceptSource: DispatchSourceRead?
|
|
17
|
+
private var service: NetService?
|
|
18
|
+
|
|
19
|
+
private override init() {
|
|
20
|
+
super.init()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func start() {
|
|
24
|
+
guard acceptSource == nil else { return }
|
|
25
|
+
|
|
26
|
+
let diag = DiagnosticLog.shared
|
|
27
|
+
serverFd = socket(AF_INET, SOCK_STREAM, 0)
|
|
28
|
+
guard serverFd >= 0 else {
|
|
29
|
+
diag.error("CompanionBridge: socket() failed — errno \(errno)")
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
var yes: Int32 = 1
|
|
34
|
+
setsockopt(serverFd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size))
|
|
35
|
+
|
|
36
|
+
var addr = sockaddr_in()
|
|
37
|
+
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
|
38
|
+
addr.sin_family = sa_family_t(AF_INET)
|
|
39
|
+
addr.sin_port = Self.defaultPort.bigEndian
|
|
40
|
+
addr.sin_addr.s_addr = INADDR_ANY.bigEndian
|
|
41
|
+
|
|
42
|
+
let bindResult = withUnsafePointer(to: &addr) {
|
|
43
|
+
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
|
44
|
+
Darwin.bind(serverFd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
guard bindResult == 0 else {
|
|
48
|
+
diag.error("CompanionBridge: bind() failed — errno \(errno)")
|
|
49
|
+
close(serverFd)
|
|
50
|
+
serverFd = -1
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
guard listen(serverFd, 8) == 0 else {
|
|
55
|
+
diag.error("CompanionBridge: listen() failed — errno \(errno)")
|
|
56
|
+
close(serverFd)
|
|
57
|
+
serverFd = -1
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let flags = fcntl(serverFd, F_GETFL)
|
|
62
|
+
_ = fcntl(serverFd, F_SETFL, flags | O_NONBLOCK)
|
|
63
|
+
|
|
64
|
+
let source = DispatchSource.makeReadSource(fileDescriptor: serverFd, queue: queue)
|
|
65
|
+
source.setEventHandler { [weak self] in
|
|
66
|
+
self?.acceptConnection()
|
|
67
|
+
}
|
|
68
|
+
source.setCancelHandler { [weak self] in
|
|
69
|
+
guard let self else { return }
|
|
70
|
+
if self.serverFd >= 0 {
|
|
71
|
+
close(self.serverFd)
|
|
72
|
+
self.serverFd = -1
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
source.resume()
|
|
76
|
+
acceptSource = source
|
|
77
|
+
|
|
78
|
+
publishBonjour()
|
|
79
|
+
diag.success("CompanionBridge: listening on http://0.0.0.0:\(Self.defaultPort)")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func stop() {
|
|
83
|
+
acceptSource?.cancel()
|
|
84
|
+
acceptSource = nil
|
|
85
|
+
service?.stop()
|
|
86
|
+
service = nil
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private extension LatticesCompanionBridgeServer {
|
|
91
|
+
struct HTTPRequest {
|
|
92
|
+
let method: String
|
|
93
|
+
let path: String
|
|
94
|
+
let headers: [String: String]
|
|
95
|
+
let body: Data
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
struct HealthResponse: Codable {
|
|
99
|
+
let ok: Bool
|
|
100
|
+
let name: String
|
|
101
|
+
let serviceType: String
|
|
102
|
+
let hostName: String
|
|
103
|
+
let port: UInt16
|
|
104
|
+
let version: String
|
|
105
|
+
let mode: String
|
|
106
|
+
let bridgePublicKey: String
|
|
107
|
+
let bridgeFingerprint: String
|
|
108
|
+
let requestSigningRequired: Bool
|
|
109
|
+
let payloadEncryptionRequired: Bool
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func publishBonjour() {
|
|
113
|
+
let advertisedName = Host.current().localizedName ?? "Lattices Companion"
|
|
114
|
+
let service = NetService(
|
|
115
|
+
domain: "local.",
|
|
116
|
+
type: Self.bonjourType,
|
|
117
|
+
name: advertisedName,
|
|
118
|
+
port: Int32(Self.defaultPort)
|
|
119
|
+
)
|
|
120
|
+
service.includesPeerToPeer = true
|
|
121
|
+
service.publish()
|
|
122
|
+
self.service = service
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func acceptConnection() {
|
|
126
|
+
while true {
|
|
127
|
+
var clientAddr = sockaddr_in()
|
|
128
|
+
var addrLen = socklen_t(MemoryLayout<sockaddr_in>.size)
|
|
129
|
+
|
|
130
|
+
let clientFd = withUnsafeMutablePointer(to: &clientAddr) {
|
|
131
|
+
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
|
132
|
+
accept(serverFd, $0, &addrLen)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if clientFd < 0 {
|
|
137
|
+
if errno != EAGAIN && errno != EWOULDBLOCK {
|
|
138
|
+
DiagnosticLog.shared.error("CompanionBridge: accept() failed — errno \(errno)")
|
|
139
|
+
}
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let clientFlags = fcntl(clientFd, F_GETFL)
|
|
144
|
+
if clientFlags >= 0 {
|
|
145
|
+
_ = fcntl(clientFd, F_SETFL, clientFlags & ~O_NONBLOCK)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
queue.async { [weak self] in
|
|
149
|
+
self?.handleClient(fd: clientFd)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func handleClient(fd: Int32) {
|
|
155
|
+
defer { close(fd) }
|
|
156
|
+
|
|
157
|
+
guard let request = readRequest(from: fd) else {
|
|
158
|
+
sendError(status: 400, message: "Invalid HTTP request", to: fd)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
do {
|
|
163
|
+
try route(request, to: fd)
|
|
164
|
+
} catch let error as LatticesCompanionSecurityError {
|
|
165
|
+
let status: Int
|
|
166
|
+
switch error {
|
|
167
|
+
case .untrustedDevice:
|
|
168
|
+
status = 403
|
|
169
|
+
case .missingHeader, .staleRequest, .replayedRequest, .invalidSignature, .invalidEnvelope, .invalidDeviceKey:
|
|
170
|
+
status = 401
|
|
171
|
+
}
|
|
172
|
+
sendError(status: status, message: error.localizedDescription, to: fd)
|
|
173
|
+
} catch {
|
|
174
|
+
sendError(status: 500, message: error.localizedDescription, to: fd)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func route(_ request: HTTPRequest, to fd: Int32) throws {
|
|
179
|
+
switch (request.method, request.path) {
|
|
180
|
+
case ("GET", "/health"):
|
|
181
|
+
let security = LatticesDeckHost.shared.securityConfiguration
|
|
182
|
+
let response = HealthResponse(
|
|
183
|
+
ok: true,
|
|
184
|
+
name: Host.current().localizedName ?? "Lattices Companion",
|
|
185
|
+
serviceType: Self.bonjourType,
|
|
186
|
+
hostName: localHostName(),
|
|
187
|
+
port: Self.defaultPort,
|
|
188
|
+
version: LatticesRuntime.appVersion,
|
|
189
|
+
mode: "local-network-secure",
|
|
190
|
+
bridgePublicKey: LatticesCompanionSecurityCoordinator.shared.bridgePublicKeyBase64,
|
|
191
|
+
bridgeFingerprint: LatticesCompanionSecurityCoordinator.shared.bridgeFingerprint,
|
|
192
|
+
requestSigningRequired: security.requestSigningRequired,
|
|
193
|
+
payloadEncryptionRequired: security.payloadEncryptionRequired
|
|
194
|
+
)
|
|
195
|
+
try sendJSON(status: 200, value: response, to: fd)
|
|
196
|
+
|
|
197
|
+
case ("GET", "/deck/manifest"):
|
|
198
|
+
try sendJSON(status: 200, value: LatticesDeckHost.shared.manifestSync(), to: fd)
|
|
199
|
+
|
|
200
|
+
case ("POST", "/pairing/request"):
|
|
201
|
+
let pairingRequest = try decoder.decode(DeckPairingRequest.self, from: request.body)
|
|
202
|
+
let response = LatticesCompanionSecurityCoordinator.shared.handlePairingRequest(pairingRequest)
|
|
203
|
+
let status = response.disposition == .denied ? 403 : 200
|
|
204
|
+
try sendJSON(status: status, value: response, to: fd)
|
|
205
|
+
|
|
206
|
+
case ("GET", "/deck/snapshot"):
|
|
207
|
+
let auth = try authorizeProtectedRequest(request)
|
|
208
|
+
let snapshot = try LatticesDeckHost.shared.runtimeSnapshotSync()
|
|
209
|
+
let response = try LatticesCompanionSecurityCoordinator.shared.encodeProtectedResponse(
|
|
210
|
+
snapshot,
|
|
211
|
+
auth: auth,
|
|
212
|
+
status: 200,
|
|
213
|
+
path: request.path
|
|
214
|
+
)
|
|
215
|
+
try sendJSON(status: 200, value: response, to: fd)
|
|
216
|
+
|
|
217
|
+
case ("POST", "/deck/perform"):
|
|
218
|
+
let auth = try authorizeProtectedRequest(request)
|
|
219
|
+
let action = try LatticesCompanionSecurityCoordinator.shared.decodeProtectedBody(
|
|
220
|
+
DeckActionRequest.self,
|
|
221
|
+
body: request.body,
|
|
222
|
+
auth: auth,
|
|
223
|
+
method: request.method,
|
|
224
|
+
path: request.path
|
|
225
|
+
)
|
|
226
|
+
let result = try LatticesDeckHost.shared.performSync(action)
|
|
227
|
+
let response = try LatticesCompanionSecurityCoordinator.shared.encodeProtectedResponse(
|
|
228
|
+
result,
|
|
229
|
+
auth: auth,
|
|
230
|
+
status: 200,
|
|
231
|
+
path: request.path
|
|
232
|
+
)
|
|
233
|
+
try sendJSON(status: 200, value: response, to: fd)
|
|
234
|
+
|
|
235
|
+
case ("POST", "/deck/trackpad"):
|
|
236
|
+
let auth = try authorizeProtectedRequest(request)
|
|
237
|
+
let eventRequest = try LatticesCompanionSecurityCoordinator.shared.decodeProtectedBody(
|
|
238
|
+
DeckTrackpadEventRequest.self,
|
|
239
|
+
body: request.body,
|
|
240
|
+
auth: auth,
|
|
241
|
+
method: request.method,
|
|
242
|
+
path: request.path
|
|
243
|
+
)
|
|
244
|
+
let result = LatticesCompanionTrackpadController.shared.perform(eventRequest)
|
|
245
|
+
let response = try LatticesCompanionSecurityCoordinator.shared.encodeProtectedResponse(
|
|
246
|
+
result,
|
|
247
|
+
auth: auth,
|
|
248
|
+
status: 200,
|
|
249
|
+
path: request.path
|
|
250
|
+
)
|
|
251
|
+
try sendJSON(status: 200, value: response, to: fd)
|
|
252
|
+
|
|
253
|
+
default:
|
|
254
|
+
sendError(status: 404, message: "Unknown route", to: fd)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func authorizeProtectedRequest(_ request: HTTPRequest) throws -> AuthorizedBridgeRequest {
|
|
259
|
+
let security = LatticesDeckHost.shared.securityConfiguration
|
|
260
|
+
guard security.requestSigningRequired else {
|
|
261
|
+
throw LatticesCompanionSecurityError.untrustedDevice
|
|
262
|
+
}
|
|
263
|
+
return try LatticesCompanionSecurityCoordinator.shared.authorize(
|
|
264
|
+
method: request.method,
|
|
265
|
+
path: request.path,
|
|
266
|
+
headers: request.headers,
|
|
267
|
+
body: request.body
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func readRequest(from fd: Int32) -> HTTPRequest? {
|
|
272
|
+
var buffer = Data()
|
|
273
|
+
let deadline = DispatchTime.now().uptimeNanoseconds + 2_000_000_000
|
|
274
|
+
let delimiterCRLF = Data([13, 10, 13, 10])
|
|
275
|
+
let delimiterLF = Data([10, 10])
|
|
276
|
+
|
|
277
|
+
var headerRange = buffer.range(of: delimiterCRLF) ?? buffer.range(of: delimiterLF)
|
|
278
|
+
while headerRange == nil {
|
|
279
|
+
guard let count = readChunk(from: fd, deadline: deadline, into: &buffer) else {
|
|
280
|
+
return nil
|
|
281
|
+
}
|
|
282
|
+
guard count > 0 else { return nil }
|
|
283
|
+
if buffer.count > 128 * 1024 {
|
|
284
|
+
return nil
|
|
285
|
+
}
|
|
286
|
+
headerRange = buffer.range(of: delimiterCRLF) ?? buffer.range(of: delimiterLF)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
guard let headerRange else { return nil }
|
|
290
|
+
let headerData = buffer.subdata(in: 0..<headerRange.lowerBound)
|
|
291
|
+
guard let headerText = String(data: headerData, encoding: .utf8) else {
|
|
292
|
+
return nil
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let lines = headerText
|
|
296
|
+
.replacingOccurrences(of: "\r\n", with: "\n")
|
|
297
|
+
.components(separatedBy: "\n")
|
|
298
|
+
guard let requestLine = lines.first else { return nil }
|
|
299
|
+
let requestParts = requestLine.split(separator: " ", omittingEmptySubsequences: true)
|
|
300
|
+
guard requestParts.count >= 2 else { return nil }
|
|
301
|
+
|
|
302
|
+
let method = String(requestParts[0]).uppercased()
|
|
303
|
+
let rawPath = String(requestParts[1])
|
|
304
|
+
let path = rawPath.split(separator: "?", maxSplits: 1).first.map(String.init) ?? rawPath
|
|
305
|
+
|
|
306
|
+
var headers: [String: String] = [:]
|
|
307
|
+
for line in lines.dropFirst() {
|
|
308
|
+
guard let separator = line.firstIndex(of: ":") else { continue }
|
|
309
|
+
let name = line[..<separator].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
310
|
+
let value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
311
|
+
headers[name] = value
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let contentLength = Int(headers["content-length"] ?? "") ?? 0
|
|
315
|
+
var body = Data(buffer[headerRange.upperBound...])
|
|
316
|
+
while body.count < contentLength {
|
|
317
|
+
guard let count = readChunk(
|
|
318
|
+
from: fd,
|
|
319
|
+
deadline: deadline,
|
|
320
|
+
chunkSize: min(4096, contentLength - body.count),
|
|
321
|
+
into: &body
|
|
322
|
+
) else {
|
|
323
|
+
return nil
|
|
324
|
+
}
|
|
325
|
+
guard count > 0 else { return nil }
|
|
326
|
+
}
|
|
327
|
+
if body.count > contentLength {
|
|
328
|
+
body = body.prefix(contentLength)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return HTTPRequest(method: method, path: path, headers: headers, body: body)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
func readChunk(
|
|
335
|
+
from fd: Int32,
|
|
336
|
+
deadline: UInt64,
|
|
337
|
+
chunkSize: Int = 4096,
|
|
338
|
+
into buffer: inout Data
|
|
339
|
+
) -> Int? {
|
|
340
|
+
var chunk = [UInt8](repeating: 0, count: chunkSize)
|
|
341
|
+
|
|
342
|
+
while true {
|
|
343
|
+
let count = Darwin.read(fd, &chunk, chunk.count)
|
|
344
|
+
if count > 0 {
|
|
345
|
+
buffer.append(contentsOf: chunk[..<count])
|
|
346
|
+
return count
|
|
347
|
+
}
|
|
348
|
+
if count == 0 {
|
|
349
|
+
return 0
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if errno == EINTR {
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
if (errno == EAGAIN || errno == EWOULDBLOCK) &&
|
|
356
|
+
DispatchTime.now().uptimeNanoseconds < deadline {
|
|
357
|
+
usleep(10_000)
|
|
358
|
+
continue
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
DiagnosticLog.shared.error("CompanionBridge: read() failed — errno \(errno)")
|
|
362
|
+
return nil
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
func sendJSON<T: Encodable>(status: Int, value: T, to fd: Int32) throws {
|
|
367
|
+
let body = try encoder.encode(value)
|
|
368
|
+
sendResponse(status: status, contentType: "application/json; charset=utf-8", body: body, to: fd)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
func sendError(status: Int, message: String, to fd: Int32) {
|
|
372
|
+
let payload: [String: Any] = ["ok": false, "error": message]
|
|
373
|
+
guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else { return }
|
|
374
|
+
sendResponse(status: status, contentType: "application/json; charset=utf-8", body: body, to: fd)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
func sendResponse(status: Int, contentType: String, body: Data, to fd: Int32) {
|
|
378
|
+
let reason = reasonPhrase(for: status)
|
|
379
|
+
let header = [
|
|
380
|
+
"HTTP/1.1 \(status) \(reason)",
|
|
381
|
+
"Content-Type: \(contentType)",
|
|
382
|
+
"Content-Length: \(body.count)",
|
|
383
|
+
"Connection: close",
|
|
384
|
+
"",
|
|
385
|
+
""
|
|
386
|
+
].joined(separator: "\r\n")
|
|
387
|
+
writeAll(Data(header.utf8), to: fd)
|
|
388
|
+
writeAll(body, to: fd)
|
|
389
|
+
_ = shutdown(fd, SHUT_WR)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
func writeAll(_ data: Data, to fd: Int32) {
|
|
393
|
+
data.withUnsafeBytes { rawBuffer in
|
|
394
|
+
guard var pointer = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return }
|
|
395
|
+
var remaining = data.count
|
|
396
|
+
while remaining > 0 {
|
|
397
|
+
let written = write(fd, pointer, remaining)
|
|
398
|
+
guard written > 0 else { return }
|
|
399
|
+
remaining -= written
|
|
400
|
+
pointer = pointer.advanced(by: written)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
func reasonPhrase(for status: Int) -> String {
|
|
406
|
+
switch status {
|
|
407
|
+
case 200: return "OK"
|
|
408
|
+
case 400: return "Bad Request"
|
|
409
|
+
case 401: return "Unauthorized"
|
|
410
|
+
case 403: return "Forbidden"
|
|
411
|
+
case 404: return "Not Found"
|
|
412
|
+
default: return "Internal Server Error"
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
func localHostName() -> String {
|
|
417
|
+
let process = Process()
|
|
418
|
+
process.executableURL = URL(fileURLWithPath: "/usr/sbin/scutil")
|
|
419
|
+
process.arguments = ["--get", "LocalHostName"]
|
|
420
|
+
|
|
421
|
+
let pipe = Pipe()
|
|
422
|
+
process.standardOutput = pipe
|
|
423
|
+
process.standardError = Pipe()
|
|
424
|
+
|
|
425
|
+
do {
|
|
426
|
+
try process.run()
|
|
427
|
+
process.waitUntilExit()
|
|
428
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
429
|
+
if let name = String(data: data, encoding: .utf8)?
|
|
430
|
+
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
431
|
+
!name.isEmpty {
|
|
432
|
+
return "\(name).local"
|
|
433
|
+
}
|
|
434
|
+
} catch { }
|
|
435
|
+
|
|
436
|
+
return Host.current().localizedName ?? "localhost"
|
|
437
|
+
}
|
|
438
|
+
}
|