@lattices/cli 0.4.14 → 0.6.0
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 +5 -7
- package/apps/mac/Info.plist +4 -4
- package/apps/mac/Lattices.app/Contents/Info.plist +4 -12
- package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/bin/lattices-app.ts +110 -17
- package/bin/lattices-build +125 -0
- package/bin/lattices-dev +89 -16
- package/bin/lattices.ts +977 -16
- package/docs/agents.md +81 -4
- package/docs/ai-chat-ux-review.md +416 -0
- package/docs/api.md +135 -3
- package/docs/app.md +30 -8
- package/docs/config.md +4 -0
- package/docs/mouse-gestures.md +60 -1
- package/docs/proposals/LAT-004-interactive-overlay-actors.md +1 -1
- package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
- package/docs/proposals/LAT-006-mira-in-lattices.md +553 -0
- package/docs/proposals/LAT-007-unified-app-shell.md +128 -0
- package/docs/reference/dewey.config.ts +2 -2
- package/docs/release.md +171 -0
- package/docs/repo-structure.md +5 -5
- package/docs/voice.md +11 -27
- package/package.json +11 -10
- package/apps/mac/Package.swift +0 -27
- package/apps/mac/Sources/AppShell/App.swift +0 -26
- package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
- package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
- package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
- package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
- package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
- package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
- package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
- package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
- package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
- package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
- package/apps/mac/Sources/AppShell/MainView.swift +0 -847
- package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
- package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
- package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
- package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
- package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
- package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
- package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
- package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
- package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
- package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
- package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
- package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
- package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
- package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
- package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
- package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
- package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
- package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
- package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
- package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
- package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
- package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
- package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
- package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
- package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
- package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
- package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
- package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
- package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
- package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
- package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
- package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
- package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
- package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
- package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
- package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
- package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
- package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
- package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
- package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
- package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
- package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
- package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
- package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
- package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
- package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
- package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
- package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
- package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
- package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
- package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
- package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
- package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
- package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
- package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
- package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
- package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
- package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
- package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
- package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
- package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
- package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
- package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
- package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2583
- package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
- package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
- package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
- package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
- package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
- package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
- package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
- package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
- package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
- package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
- package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
- package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
- package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
- package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
- package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
- package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
- package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
- package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
- package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
- package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
- package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
- package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
- package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
- package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
- package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
- package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
- package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
- package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
- package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
- package/apps/mac/Sources/Core/System/Capability.swift +0 -79
- package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
- package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
- package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
- package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
- package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
- package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
- package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
- package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
- package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
- package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
- package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
- package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
- package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
- package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
- package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
- package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
- package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
- package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
- package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
- package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
- package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
- package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
- package/apps/mac/Sources/UI/ActionRow.swift +0 -78
- package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
- package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
- package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
- package/apps/mac/Sources/UI/Theme.swift +0 -164
- package/apps/mac/Tests/StageDragTests.swift +0 -333
- package/apps/mac/Tests/StageJoinTests.swift +0 -313
- package/apps/mac/Tests/StageManagerTests.swift +0 -280
- package/apps/mac/Tests/StageTileTests.swift +0 -353
- package/swift/Package.swift +0 -20
- package/swift/Sources/DeckKit/DeckAction.swift +0 -51
- package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
- package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
- package/swift/Sources/DeckKit/DeckHost.swift +0 -7
- package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
- package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
- package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
- package/swift/Sources/DeckKit/DeckValue.swift +0 -93
- package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
- package/swift/Tests/DeckKitTests/DeckKitTests.swift +0 -286
|
@@ -1,629 +0,0 @@
|
|
|
1
|
-
import AppKit
|
|
2
|
-
import CryptoKit
|
|
3
|
-
import DeckKit
|
|
4
|
-
import Foundation
|
|
5
|
-
import Security
|
|
6
|
-
|
|
7
|
-
enum LatticesCompanionSecurityError: LocalizedError {
|
|
8
|
-
case missingHeader(String)
|
|
9
|
-
case untrustedDevice
|
|
10
|
-
case staleRequest
|
|
11
|
-
case replayedRequest
|
|
12
|
-
case invalidSignature
|
|
13
|
-
case invalidEnvelope
|
|
14
|
-
case invalidDeviceKey
|
|
15
|
-
case insufficientCapability(String)
|
|
16
|
-
|
|
17
|
-
var errorDescription: String? {
|
|
18
|
-
switch self {
|
|
19
|
-
case .missingHeader(let name):
|
|
20
|
-
return "Missing bridge security header: \(name)."
|
|
21
|
-
case .untrustedDevice:
|
|
22
|
-
return "This device is not trusted by the Mac bridge yet."
|
|
23
|
-
case .staleRequest:
|
|
24
|
-
return "This bridge request expired before it reached the Mac."
|
|
25
|
-
case .replayedRequest:
|
|
26
|
-
return "This bridge request was already used."
|
|
27
|
-
case .invalidSignature:
|
|
28
|
-
return "The bridge request signature could not be verified."
|
|
29
|
-
case .invalidEnvelope:
|
|
30
|
-
return "The bridge payload could not be decrypted."
|
|
31
|
-
case .invalidDeviceKey:
|
|
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)."
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
struct LatticesCompanionTrustedDeviceRecord: Codable, Equatable, Identifiable, Sendable {
|
|
40
|
-
var id: String
|
|
41
|
-
var name: String
|
|
42
|
-
var publicKey: String
|
|
43
|
-
var fingerprint: String
|
|
44
|
-
var platform: String
|
|
45
|
-
var appVersion: String?
|
|
46
|
-
var capabilities: [String]?
|
|
47
|
-
var pairedAt: Date
|
|
48
|
-
var lastSeenAt: Date
|
|
49
|
-
|
|
50
|
-
var summary: DeckTrustedDeviceSummary {
|
|
51
|
-
DeckTrustedDeviceSummary(
|
|
52
|
-
id: id,
|
|
53
|
-
name: name,
|
|
54
|
-
fingerprint: fingerprint,
|
|
55
|
-
capabilities: effectiveCapabilities,
|
|
56
|
-
pairedAt: pairedAt,
|
|
57
|
-
lastSeenAt: lastSeenAt
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
var effectiveCapabilities: [String] {
|
|
62
|
-
capabilities ?? DeckBridgeCapability.defaultCompanionCapabilities
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
struct AuthorizedBridgeRequest {
|
|
67
|
-
let device: LatticesCompanionTrustedDeviceRecord
|
|
68
|
-
let requestNonce: String
|
|
69
|
-
let requestTimestamp: String
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
final class LatticesCompanionSecurityCoordinator {
|
|
73
|
-
static let shared = LatticesCompanionSecurityCoordinator()
|
|
74
|
-
|
|
75
|
-
private enum DefaultsKey {
|
|
76
|
-
static let trustedDevices = "companion.security.trustedDevices"
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
private enum KeychainKey {
|
|
80
|
-
static let service = "com.arach.lattices.companion.bridge"
|
|
81
|
-
static let account = "bridge.keyagreement.private"
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
private enum Header {
|
|
85
|
-
static let deviceID = "x-lattices-device-id"
|
|
86
|
-
static let timestamp = "x-lattices-timestamp"
|
|
87
|
-
static let nonce = "x-lattices-nonce"
|
|
88
|
-
static let signature = "x-lattices-signature"
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
private let encoder = JSONEncoder()
|
|
92
|
-
private let decoder = JSONDecoder()
|
|
93
|
-
private let bridgePrivateKey: Curve25519.KeyAgreement.PrivateKey
|
|
94
|
-
private let timeSkewAllowance: TimeInterval = 120
|
|
95
|
-
private let replayWindow: TimeInterval = 600
|
|
96
|
-
|
|
97
|
-
private var trustedDevices: [String: LatticesCompanionTrustedDeviceRecord]
|
|
98
|
-
private var seenNonces: [String: Date] = [:]
|
|
99
|
-
|
|
100
|
-
private init() {
|
|
101
|
-
self.bridgePrivateKey = Self.loadOrCreateBridgeKey()
|
|
102
|
-
self.trustedDevices = Self.loadTrustedDevices()
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
var bridgePublicKeyBase64: String {
|
|
106
|
-
Data(bridgePrivateKey.publicKey.rawRepresentation).base64EncodedString()
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
var bridgeFingerprint: String {
|
|
110
|
-
Self.fingerprint(forPublicKeyBase64: bridgePublicKeyBase64)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
func trustedDeviceSummaries() -> [DeckTrustedDeviceSummary] {
|
|
114
|
-
trustedDevices.values
|
|
115
|
-
.map(\.summary)
|
|
116
|
-
.sorted { lhs, rhs in
|
|
117
|
-
if lhs.lastSeenAt == rhs.lastSeenAt {
|
|
118
|
-
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
|
119
|
-
}
|
|
120
|
-
return lhs.lastSeenAt > rhs.lastSeenAt
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
func clearTrustedDevices() {
|
|
125
|
-
trustedDevices.removeAll()
|
|
126
|
-
persistTrustedDevices()
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
func revokeTrustedDevice(id: String) {
|
|
130
|
-
trustedDevices.removeValue(forKey: id)
|
|
131
|
-
persistTrustedDevices()
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
func handlePairingRequest(_ request: DeckPairingRequest) -> DeckPairingResponse {
|
|
135
|
-
let diag = DiagnosticLog.shared
|
|
136
|
-
diag.info("CompanionPairing: request device=\(request.deviceName) id=\(request.deviceID)")
|
|
137
|
-
let grantedCapabilities = Self.grantedCapabilities(for: request.requestedCapabilities)
|
|
138
|
-
|
|
139
|
-
guard
|
|
140
|
-
request.deviceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
|
|
141
|
-
request.deviceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
|
|
142
|
-
decodePublicKey(base64: request.devicePublicKey) != nil
|
|
143
|
-
else {
|
|
144
|
-
diag.warn("CompanionPairing: invalid key material for device id=\(request.deviceID)")
|
|
145
|
-
return DeckPairingResponse(
|
|
146
|
-
disposition: .denied,
|
|
147
|
-
bridgeName: Host.current().localizedName ?? "Lattices Companion",
|
|
148
|
-
bridgePublicKey: bridgePublicKeyBase64,
|
|
149
|
-
bridgeFingerprint: bridgeFingerprint,
|
|
150
|
-
requestSigningRequired: true,
|
|
151
|
-
payloadEncryptionRequired: true,
|
|
152
|
-
grantedCapabilities: [],
|
|
153
|
-
detail: LatticesCompanionSecurityError.invalidDeviceKey.localizedDescription
|
|
154
|
-
)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if var existing = trustedDevices[request.deviceID], existing.publicKey == request.devicePublicKey {
|
|
158
|
-
existing.lastSeenAt = Date()
|
|
159
|
-
existing.capabilities = grantedCapabilities.isEmpty ? existing.effectiveCapabilities : grantedCapabilities
|
|
160
|
-
trustedDevices[request.deviceID] = existing
|
|
161
|
-
persistTrustedDevices()
|
|
162
|
-
diag.success("CompanionPairing: device already trusted id=\(request.deviceID)")
|
|
163
|
-
return DeckPairingResponse(
|
|
164
|
-
disposition: .alreadyTrusted,
|
|
165
|
-
bridgeName: Host.current().localizedName ?? "Lattices Companion",
|
|
166
|
-
bridgePublicKey: bridgePublicKeyBase64,
|
|
167
|
-
bridgeFingerprint: bridgeFingerprint,
|
|
168
|
-
requestSigningRequired: true,
|
|
169
|
-
payloadEncryptionRequired: true,
|
|
170
|
-
grantedCapabilities: existing.effectiveCapabilities,
|
|
171
|
-
detail: "This device is already trusted on the Mac."
|
|
172
|
-
)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
let approved = promptForPairingApproval(request)
|
|
176
|
-
guard approved else {
|
|
177
|
-
diag.warn("CompanionPairing: denied device id=\(request.deviceID)")
|
|
178
|
-
return DeckPairingResponse(
|
|
179
|
-
disposition: .denied,
|
|
180
|
-
bridgeName: Host.current().localizedName ?? "Lattices Companion",
|
|
181
|
-
bridgePublicKey: bridgePublicKeyBase64,
|
|
182
|
-
bridgeFingerprint: bridgeFingerprint,
|
|
183
|
-
requestSigningRequired: true,
|
|
184
|
-
payloadEncryptionRequired: true,
|
|
185
|
-
grantedCapabilities: [],
|
|
186
|
-
detail: "Pairing was denied on the Mac."
|
|
187
|
-
)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
let now = Date()
|
|
191
|
-
trustedDevices[request.deviceID] = LatticesCompanionTrustedDeviceRecord(
|
|
192
|
-
id: request.deviceID,
|
|
193
|
-
name: request.deviceName,
|
|
194
|
-
publicKey: request.devicePublicKey,
|
|
195
|
-
fingerprint: Self.fingerprint(forPublicKeyBase64: request.devicePublicKey),
|
|
196
|
-
platform: request.platform,
|
|
197
|
-
appVersion: request.appVersion,
|
|
198
|
-
capabilities: grantedCapabilities,
|
|
199
|
-
pairedAt: now,
|
|
200
|
-
lastSeenAt: now
|
|
201
|
-
)
|
|
202
|
-
persistTrustedDevices()
|
|
203
|
-
diag.success("CompanionPairing: approved device id=\(request.deviceID)")
|
|
204
|
-
|
|
205
|
-
return DeckPairingResponse(
|
|
206
|
-
disposition: .approved,
|
|
207
|
-
bridgeName: Host.current().localizedName ?? "Lattices Companion",
|
|
208
|
-
bridgePublicKey: bridgePublicKeyBase64,
|
|
209
|
-
bridgeFingerprint: bridgeFingerprint,
|
|
210
|
-
requestSigningRequired: true,
|
|
211
|
-
payloadEncryptionRequired: true,
|
|
212
|
-
grantedCapabilities: grantedCapabilities,
|
|
213
|
-
detail: "Trusted and ready for encrypted bridge requests."
|
|
214
|
-
)
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
func authorize(
|
|
218
|
-
method: String,
|
|
219
|
-
path: String,
|
|
220
|
-
headers: [String: String],
|
|
221
|
-
body: Data
|
|
222
|
-
) throws -> AuthorizedBridgeRequest {
|
|
223
|
-
let deviceID = try requiredHeader(Header.deviceID, from: headers)
|
|
224
|
-
let timestamp = try requiredHeader(Header.timestamp, from: headers)
|
|
225
|
-
let requestNonce = try requiredHeader(Header.nonce, from: headers)
|
|
226
|
-
let signature = try requiredHeader(Header.signature, from: headers)
|
|
227
|
-
|
|
228
|
-
guard let device = trustedDevices[deviceID] else {
|
|
229
|
-
throw LatticesCompanionSecurityError.untrustedDevice
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
let requestDate = try parseRequestDate(timestamp)
|
|
233
|
-
let now = Date()
|
|
234
|
-
guard abs(requestDate.timeIntervalSince(now)) <= timeSkewAllowance else {
|
|
235
|
-
throw LatticesCompanionSecurityError.staleRequest
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
pruneSeenNonces(now: now)
|
|
239
|
-
let replayKey = "\(deviceID):\(requestNonce)"
|
|
240
|
-
guard seenNonces[replayKey] == nil else {
|
|
241
|
-
throw LatticesCompanionSecurityError.replayedRequest
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
let expectedSignature = try requestSignature(
|
|
245
|
-
method: method,
|
|
246
|
-
path: path,
|
|
247
|
-
device: device,
|
|
248
|
-
timestamp: timestamp,
|
|
249
|
-
requestNonce: requestNonce,
|
|
250
|
-
body: body
|
|
251
|
-
)
|
|
252
|
-
guard Self.constantTimeEquals(signature, expectedSignature) else {
|
|
253
|
-
throw LatticesCompanionSecurityError.invalidSignature
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
seenNonces[replayKey] = now
|
|
257
|
-
touchDevice(deviceID: deviceID, at: now)
|
|
258
|
-
return AuthorizedBridgeRequest(
|
|
259
|
-
device: trustedDevices[deviceID] ?? device,
|
|
260
|
-
requestNonce: requestNonce,
|
|
261
|
-
requestTimestamp: timestamp
|
|
262
|
-
)
|
|
263
|
-
}
|
|
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
|
-
|
|
271
|
-
func decodeProtectedBody<T: Decodable>(
|
|
272
|
-
_ type: T.Type,
|
|
273
|
-
body: Data,
|
|
274
|
-
auth: AuthorizedBridgeRequest,
|
|
275
|
-
method: String,
|
|
276
|
-
path: String
|
|
277
|
-
) throws -> T {
|
|
278
|
-
let envelope = try decoder.decode(DeckEncryptedEnvelope.self, from: body)
|
|
279
|
-
let plaintext = try openEnvelope(
|
|
280
|
-
envelope,
|
|
281
|
-
device: auth.device,
|
|
282
|
-
aad: requestAAD(
|
|
283
|
-
method: method,
|
|
284
|
-
path: path,
|
|
285
|
-
deviceID: auth.device.id,
|
|
286
|
-
timestamp: auth.requestTimestamp,
|
|
287
|
-
requestNonce: auth.requestNonce
|
|
288
|
-
)
|
|
289
|
-
)
|
|
290
|
-
return try decoder.decode(type, from: plaintext)
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
func encodeProtectedResponse<T: Encodable>(
|
|
294
|
-
_ value: T,
|
|
295
|
-
auth: AuthorizedBridgeRequest,
|
|
296
|
-
status: Int,
|
|
297
|
-
path: String
|
|
298
|
-
) throws -> DeckEncryptedEnvelope {
|
|
299
|
-
let plaintext = try encoder.encode(value)
|
|
300
|
-
return try sealEnvelope(
|
|
301
|
-
plaintext,
|
|
302
|
-
device: auth.device,
|
|
303
|
-
aad: responseAAD(
|
|
304
|
-
status: status,
|
|
305
|
-
path: path,
|
|
306
|
-
deviceID: auth.device.id,
|
|
307
|
-
requestNonce: auth.requestNonce
|
|
308
|
-
)
|
|
309
|
-
)
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
private extension LatticesCompanionSecurityCoordinator {
|
|
314
|
-
static func loadTrustedDevices() -> [String: LatticesCompanionTrustedDeviceRecord] {
|
|
315
|
-
guard
|
|
316
|
-
let data = UserDefaults.standard.data(forKey: DefaultsKey.trustedDevices),
|
|
317
|
-
let devices = try? JSONDecoder().decode([LatticesCompanionTrustedDeviceRecord].self, from: data)
|
|
318
|
-
else {
|
|
319
|
-
return [:]
|
|
320
|
-
}
|
|
321
|
-
return Dictionary(uniqueKeysWithValues: devices.map { ($0.id, $0) })
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
static func loadOrCreateBridgeKey() -> Curve25519.KeyAgreement.PrivateKey {
|
|
325
|
-
if
|
|
326
|
-
let stored = KeychainBridge.load(service: KeychainKey.service, account: KeychainKey.account),
|
|
327
|
-
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: stored)
|
|
328
|
-
{
|
|
329
|
-
return key
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
let key = Curve25519.KeyAgreement.PrivateKey()
|
|
333
|
-
let data = key.rawRepresentation
|
|
334
|
-
_ = KeychainBridge.save(data, service: KeychainKey.service, account: KeychainKey.account)
|
|
335
|
-
return key
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
static func fingerprint(forPublicKeyBase64 value: String) -> String {
|
|
339
|
-
let digest = SHA256.hash(data: Data(value.utf8))
|
|
340
|
-
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
|
341
|
-
let compact = String(hex.prefix(12)).uppercased()
|
|
342
|
-
return compact.chunked(into: 4).joined(separator: "-")
|
|
343
|
-
}
|
|
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
|
-
|
|
353
|
-
func persistTrustedDevices() {
|
|
354
|
-
let sorted = trustedDevices.values.sorted { lhs, rhs in
|
|
355
|
-
lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
|
356
|
-
}
|
|
357
|
-
guard let data = try? encoder.encode(sorted) else { return }
|
|
358
|
-
UserDefaults.standard.set(data, forKey: DefaultsKey.trustedDevices)
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
func requiredHeader(_ name: String, from headers: [String: String]) throws -> String {
|
|
362
|
-
guard let value = headers[name], value.isEmpty == false else {
|
|
363
|
-
throw LatticesCompanionSecurityError.missingHeader(name)
|
|
364
|
-
}
|
|
365
|
-
return value
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
func parseRequestDate(_ timestamp: String) throws -> Date {
|
|
369
|
-
guard let value = ISO8601DateFormatter.latticesBridge.date(from: timestamp) else {
|
|
370
|
-
throw LatticesCompanionSecurityError.staleRequest
|
|
371
|
-
}
|
|
372
|
-
return value
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
func requestSignature(
|
|
376
|
-
method: String,
|
|
377
|
-
path: String,
|
|
378
|
-
device: LatticesCompanionTrustedDeviceRecord,
|
|
379
|
-
timestamp: String,
|
|
380
|
-
requestNonce: String,
|
|
381
|
-
body: Data
|
|
382
|
-
) throws -> String {
|
|
383
|
-
let key = try signingKey(for: device)
|
|
384
|
-
let canonical = requestCanonicalData(
|
|
385
|
-
method: method,
|
|
386
|
-
path: path,
|
|
387
|
-
deviceID: device.id,
|
|
388
|
-
timestamp: timestamp,
|
|
389
|
-
requestNonce: requestNonce,
|
|
390
|
-
body: body
|
|
391
|
-
)
|
|
392
|
-
let mac = HMAC<SHA256>.authenticationCode(for: canonical, using: key)
|
|
393
|
-
return Data(mac).base64EncodedString()
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
func requestCanonicalData(
|
|
397
|
-
method: String,
|
|
398
|
-
path: String,
|
|
399
|
-
deviceID: String,
|
|
400
|
-
timestamp: String,
|
|
401
|
-
requestNonce: String,
|
|
402
|
-
body: Data
|
|
403
|
-
) -> Data {
|
|
404
|
-
let digest = SHA256.hash(data: body)
|
|
405
|
-
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
|
406
|
-
let canonical = [
|
|
407
|
-
method.uppercased(),
|
|
408
|
-
path,
|
|
409
|
-
deviceID,
|
|
410
|
-
timestamp,
|
|
411
|
-
requestNonce,
|
|
412
|
-
hex
|
|
413
|
-
].joined(separator: "\n")
|
|
414
|
-
return Data(canonical.utf8)
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
func requestAAD(
|
|
418
|
-
method: String,
|
|
419
|
-
path: String,
|
|
420
|
-
deviceID: String,
|
|
421
|
-
timestamp: String,
|
|
422
|
-
requestNonce: String
|
|
423
|
-
) -> Data {
|
|
424
|
-
let value = [
|
|
425
|
-
"request",
|
|
426
|
-
method.uppercased(),
|
|
427
|
-
path,
|
|
428
|
-
deviceID,
|
|
429
|
-
timestamp,
|
|
430
|
-
requestNonce
|
|
431
|
-
].joined(separator: "\n")
|
|
432
|
-
return Data(value.utf8)
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
func responseAAD(
|
|
436
|
-
status: Int,
|
|
437
|
-
path: String,
|
|
438
|
-
deviceID: String,
|
|
439
|
-
requestNonce: String
|
|
440
|
-
) -> Data {
|
|
441
|
-
let value = [
|
|
442
|
-
"response",
|
|
443
|
-
String(status),
|
|
444
|
-
path,
|
|
445
|
-
deviceID,
|
|
446
|
-
requestNonce
|
|
447
|
-
].joined(separator: "\n")
|
|
448
|
-
return Data(value.utf8)
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
func signingKey(for device: LatticesCompanionTrustedDeviceRecord) throws -> SymmetricKey {
|
|
452
|
-
let publicKey = try trustedPublicKey(for: device)
|
|
453
|
-
let sharedSecret = try bridgePrivateKey.sharedSecretFromKeyAgreement(with: publicKey)
|
|
454
|
-
return sharedSecret.hkdfDerivedSymmetricKey(
|
|
455
|
-
using: SHA256.self,
|
|
456
|
-
salt: Data("lattices-bridge-v1".utf8),
|
|
457
|
-
sharedInfo: Data("signing".utf8),
|
|
458
|
-
outputByteCount: 32
|
|
459
|
-
)
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
func encryptionKey(for device: LatticesCompanionTrustedDeviceRecord) throws -> SymmetricKey {
|
|
463
|
-
let publicKey = try trustedPublicKey(for: device)
|
|
464
|
-
let sharedSecret = try bridgePrivateKey.sharedSecretFromKeyAgreement(with: publicKey)
|
|
465
|
-
return sharedSecret.hkdfDerivedSymmetricKey(
|
|
466
|
-
using: SHA256.self,
|
|
467
|
-
salt: Data("lattices-bridge-v1".utf8),
|
|
468
|
-
sharedInfo: Data("encryption".utf8),
|
|
469
|
-
outputByteCount: 32
|
|
470
|
-
)
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
func trustedPublicKey(for device: LatticesCompanionTrustedDeviceRecord) throws -> Curve25519.KeyAgreement.PublicKey {
|
|
474
|
-
guard let key = decodePublicKey(base64: device.publicKey) else {
|
|
475
|
-
throw LatticesCompanionSecurityError.invalidDeviceKey
|
|
476
|
-
}
|
|
477
|
-
return key
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
func decodePublicKey(base64: String) -> Curve25519.KeyAgreement.PublicKey? {
|
|
481
|
-
guard let data = Data(base64Encoded: base64) else { return nil }
|
|
482
|
-
return try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: data)
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
func openEnvelope(
|
|
486
|
-
_ envelope: DeckEncryptedEnvelope,
|
|
487
|
-
device: LatticesCompanionTrustedDeviceRecord,
|
|
488
|
-
aad: Data
|
|
489
|
-
) throws -> Data {
|
|
490
|
-
guard let data = Data(base64Encoded: envelope.sealedBox),
|
|
491
|
-
let sealed = try? ChaChaPoly.SealedBox(combined: data) else {
|
|
492
|
-
throw LatticesCompanionSecurityError.invalidEnvelope
|
|
493
|
-
}
|
|
494
|
-
let key = try encryptionKey(for: device)
|
|
495
|
-
guard let plaintext = try? ChaChaPoly.open(sealed, using: key, authenticating: aad) else {
|
|
496
|
-
throw LatticesCompanionSecurityError.invalidEnvelope
|
|
497
|
-
}
|
|
498
|
-
return plaintext
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
func sealEnvelope(
|
|
502
|
-
_ plaintext: Data,
|
|
503
|
-
device: LatticesCompanionTrustedDeviceRecord,
|
|
504
|
-
aad: Data
|
|
505
|
-
) throws -> DeckEncryptedEnvelope {
|
|
506
|
-
let key = try encryptionKey(for: device)
|
|
507
|
-
let sealed = try ChaChaPoly.seal(plaintext, using: key, authenticating: aad)
|
|
508
|
-
return DeckEncryptedEnvelope(sealedBox: Data(sealed.combined).base64EncodedString())
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
func pruneSeenNonces(now: Date) {
|
|
512
|
-
seenNonces = seenNonces.filter { now.timeIntervalSince($0.value) < replayWindow }
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
func touchDevice(deviceID: String, at date: Date) {
|
|
516
|
-
guard var device = trustedDevices[deviceID] else { return }
|
|
517
|
-
guard date.timeIntervalSince(device.lastSeenAt) >= 30 else { return }
|
|
518
|
-
device.lastSeenAt = date
|
|
519
|
-
trustedDevices[deviceID] = device
|
|
520
|
-
persistTrustedDevices()
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
func promptForPairingApproval(_ request: DeckPairingRequest) -> Bool {
|
|
524
|
-
if Thread.isMainThread {
|
|
525
|
-
return runPairingAlert(request)
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
let semaphore = DispatchSemaphore(value: 0)
|
|
529
|
-
var approved = false
|
|
530
|
-
|
|
531
|
-
DispatchQueue.main.async {
|
|
532
|
-
approved = self.runPairingAlert(request)
|
|
533
|
-
semaphore.signal()
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
semaphore.wait()
|
|
537
|
-
return approved
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
func runPairingAlert(_ request: DeckPairingRequest) -> Bool {
|
|
541
|
-
NSApp.activate(ignoringOtherApps: true)
|
|
542
|
-
|
|
543
|
-
let alert = NSAlert()
|
|
544
|
-
alert.alertStyle = .warning
|
|
545
|
-
alert.messageText = "Allow \(request.deviceName) to pair with Lattices?"
|
|
546
|
-
alert.informativeText = """
|
|
547
|
-
This device is asking for encrypted local-network control of your Mac.
|
|
548
|
-
|
|
549
|
-
Device ID: \(request.deviceID)
|
|
550
|
-
Device Fingerprint: \(Self.fingerprint(forPublicKeyBase64: request.devicePublicKey))
|
|
551
|
-
Platform: \(request.platform)
|
|
552
|
-
"""
|
|
553
|
-
alert.addButton(withTitle: "Allow Pairing")
|
|
554
|
-
alert.addButton(withTitle: "Deny")
|
|
555
|
-
return alert.runModal() == .alertFirstButtonReturn
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
static func constantTimeEquals(_ lhs: String, _ rhs: String) -> Bool {
|
|
559
|
-
let lhsData = Array(lhs.utf8)
|
|
560
|
-
let rhsData = Array(rhs.utf8)
|
|
561
|
-
guard lhsData.count == rhsData.count else { return false }
|
|
562
|
-
var diff: UInt8 = 0
|
|
563
|
-
for index in lhsData.indices {
|
|
564
|
-
diff |= lhsData[index] ^ rhsData[index]
|
|
565
|
-
}
|
|
566
|
-
return diff == 0
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
private enum KeychainBridge {
|
|
571
|
-
static func load(service: String, account: String) -> Data? {
|
|
572
|
-
let query: [String: Any] = [
|
|
573
|
-
kSecClass as String: kSecClassGenericPassword,
|
|
574
|
-
kSecAttrService as String: service,
|
|
575
|
-
kSecAttrAccount as String: account,
|
|
576
|
-
kSecReturnData as String: true,
|
|
577
|
-
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
578
|
-
]
|
|
579
|
-
|
|
580
|
-
var result: CFTypeRef?
|
|
581
|
-
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
582
|
-
guard status == errSecSuccess else { return nil }
|
|
583
|
-
return result as? Data
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
static func save(_ data: Data, service: String, account: String) -> Bool {
|
|
587
|
-
let query: [String: Any] = [
|
|
588
|
-
kSecClass as String: kSecClassGenericPassword,
|
|
589
|
-
kSecAttrService as String: service,
|
|
590
|
-
kSecAttrAccount as String: account,
|
|
591
|
-
]
|
|
592
|
-
|
|
593
|
-
let attributes: [String: Any] = [
|
|
594
|
-
kSecValueData as String: data,
|
|
595
|
-
]
|
|
596
|
-
|
|
597
|
-
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
|
598
|
-
if updateStatus == errSecSuccess {
|
|
599
|
-
return true
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
var insert = query
|
|
603
|
-
insert[kSecValueData as String] = data
|
|
604
|
-
let addStatus = SecItemAdd(insert as CFDictionary, nil)
|
|
605
|
-
return addStatus == errSecSuccess
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
private extension ISO8601DateFormatter {
|
|
610
|
-
static let latticesBridge: ISO8601DateFormatter = {
|
|
611
|
-
let formatter = ISO8601DateFormatter()
|
|
612
|
-
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
613
|
-
return formatter
|
|
614
|
-
}()
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
private extension String {
|
|
618
|
-
func chunked(into size: Int) -> [String] {
|
|
619
|
-
guard size > 0, isEmpty == false else { return [self] }
|
|
620
|
-
var chunks: [String] = []
|
|
621
|
-
var index = startIndex
|
|
622
|
-
while index < endIndex {
|
|
623
|
-
let next = self.index(index, offsetBy: size, limitedBy: endIndex) ?? endIndex
|
|
624
|
-
chunks.append(String(self[index..<next]))
|
|
625
|
-
index = next
|
|
626
|
-
}
|
|
627
|
-
return chunks
|
|
628
|
-
}
|
|
629
|
-
}
|