@lattices/cli 0.3.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 +155 -0
- package/app/Lattices.app/Contents/Info.plist +24 -0
- package/app/Package.swift +13 -0
- package/app/Sources/AccessibilityTextExtractor.swift +111 -0
- package/app/Sources/ActionRow.swift +61 -0
- package/app/Sources/App.swift +10 -0
- package/app/Sources/AppDelegate.swift +242 -0
- package/app/Sources/AppShellView.swift +62 -0
- package/app/Sources/AppTypeClassifier.swift +70 -0
- package/app/Sources/AppWindowShell.swift +63 -0
- package/app/Sources/CheatSheetHUD.swift +332 -0
- package/app/Sources/CommandModeState.swift +1362 -0
- package/app/Sources/CommandModeView.swift +1405 -0
- package/app/Sources/CommandModeWindow.swift +192 -0
- package/app/Sources/CommandPaletteView.swift +307 -0
- package/app/Sources/CommandPaletteWindow.swift +134 -0
- package/app/Sources/DaemonProtocol.swift +101 -0
- package/app/Sources/DaemonServer.swift +414 -0
- package/app/Sources/DesktopModel.swift +149 -0
- package/app/Sources/DesktopModelTypes.swift +71 -0
- package/app/Sources/DiagnosticLog.swift +271 -0
- package/app/Sources/EventBus.swift +30 -0
- package/app/Sources/HotkeyManager.swift +254 -0
- package/app/Sources/HotkeyStore.swift +338 -0
- package/app/Sources/InventoryManager.swift +35 -0
- package/app/Sources/InventoryPath.swift +43 -0
- package/app/Sources/KeyRecorderView.swift +210 -0
- package/app/Sources/LatticesApi.swift +1234 -0
- package/app/Sources/LayerBezel.swift +203 -0
- package/app/Sources/MainView.swift +479 -0
- package/app/Sources/MainWindow.swift +83 -0
- package/app/Sources/OcrModel.swift +430 -0
- package/app/Sources/OcrStore.swift +329 -0
- package/app/Sources/OmniSearchState.swift +283 -0
- package/app/Sources/OmniSearchView.swift +288 -0
- package/app/Sources/OmniSearchWindow.swift +105 -0
- package/app/Sources/OrphanRow.swift +129 -0
- package/app/Sources/PaletteCommand.swift +419 -0
- package/app/Sources/PermissionChecker.swift +125 -0
- package/app/Sources/Preferences.swift +99 -0
- package/app/Sources/ProcessModel.swift +199 -0
- package/app/Sources/ProcessQuery.swift +151 -0
- package/app/Sources/Project.swift +28 -0
- package/app/Sources/ProjectRow.swift +368 -0
- package/app/Sources/ProjectScanner.swift +128 -0
- package/app/Sources/ScreenMapState.swift +2387 -0
- package/app/Sources/ScreenMapView.swift +2820 -0
- package/app/Sources/ScreenMapWindowController.swift +89 -0
- package/app/Sources/SessionManager.swift +72 -0
- package/app/Sources/SettingsView.swift +1064 -0
- package/app/Sources/SettingsWindow.swift +20 -0
- package/app/Sources/TabGroupRow.swift +178 -0
- package/app/Sources/Terminal.swift +259 -0
- package/app/Sources/TerminalQuery.swift +156 -0
- package/app/Sources/TerminalSynthesizer.swift +200 -0
- package/app/Sources/Theme.swift +163 -0
- package/app/Sources/TilePickerView.swift +209 -0
- package/app/Sources/TmuxModel.swift +53 -0
- package/app/Sources/TmuxQuery.swift +81 -0
- package/app/Sources/WindowTiler.swift +1778 -0
- package/app/Sources/WorkspaceManager.swift +575 -0
- package/bin/client.js +4 -0
- package/bin/daemon-client.js +187 -0
- package/bin/lattices-app.js +221 -0
- package/bin/lattices.js +1551 -0
- package/docs/api.md +924 -0
- package/docs/app.md +297 -0
- package/docs/concepts.md +135 -0
- package/docs/config.md +245 -0
- package/docs/layers.md +410 -0
- package/docs/ocr.md +185 -0
- package/docs/overview.md +94 -0
- package/docs/quickstart.md +75 -0
- package/package.json +42 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
final class ProcessModel: ObservableObject {
|
|
4
|
+
static let shared = ProcessModel()
|
|
5
|
+
|
|
6
|
+
@Published private(set) var processTable: [Int: ProcessEntry] = [:]
|
|
7
|
+
@Published private(set) var childrenMap: [Int: [Int]] = [:] // ppid → [child pids]
|
|
8
|
+
@Published private(set) var interesting: [ProcessEntry] = []
|
|
9
|
+
|
|
10
|
+
private var timer: DispatchSourceTimer?
|
|
11
|
+
private var lastInterestingPids: Set<Int> = []
|
|
12
|
+
|
|
13
|
+
// Terminal tab cache — refreshed lazily when terminals are queried
|
|
14
|
+
private var cachedTerminalTabs: [TerminalTab] = []
|
|
15
|
+
private var lastTabQueryTime: Date = .distantPast
|
|
16
|
+
private static let tabCacheTTL: TimeInterval = 300.0 // 5 minutes
|
|
17
|
+
|
|
18
|
+
/// Background queue for process polling — avoids blocking the main thread
|
|
19
|
+
/// with posix_spawn calls (waitUntilExit deadlocks on macOS 26 main run loop).
|
|
20
|
+
private let pollQueue = DispatchQueue(label: "lattices.process-poll", qos: .userInitiated)
|
|
21
|
+
|
|
22
|
+
func start(interval: TimeInterval = 5.0) {
|
|
23
|
+
guard timer == nil else { return }
|
|
24
|
+
DiagnosticLog.shared.info("ProcessModel: starting (interval=\(interval)s)")
|
|
25
|
+
|
|
26
|
+
let source = DispatchSource.makeTimerSource(queue: pollQueue)
|
|
27
|
+
source.schedule(deadline: .now(), repeating: interval)
|
|
28
|
+
source.setEventHandler { [weak self] in
|
|
29
|
+
self?.poll()
|
|
30
|
+
}
|
|
31
|
+
source.resume()
|
|
32
|
+
timer = source
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func stop() {
|
|
36
|
+
timer?.cancel()
|
|
37
|
+
timer = nil
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// MARK: - Query Methods
|
|
41
|
+
|
|
42
|
+
/// All interesting developer processes with CWDs resolved.
|
|
43
|
+
func interestingProcesses() -> [ProcessEntry] {
|
|
44
|
+
interesting
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// BFS walk all descendants of a given PID.
|
|
48
|
+
func descendants(of pid: Int) -> [ProcessEntry] {
|
|
49
|
+
var result: [ProcessEntry] = []
|
|
50
|
+
var queue = childrenMap[pid] ?? []
|
|
51
|
+
var visited: Set<Int> = [pid]
|
|
52
|
+
|
|
53
|
+
while !queue.isEmpty {
|
|
54
|
+
let childPid = queue.removeFirst()
|
|
55
|
+
guard !visited.contains(childPid) else { continue }
|
|
56
|
+
visited.insert(childPid)
|
|
57
|
+
if let entry = processTable[childPid] {
|
|
58
|
+
result.append(entry)
|
|
59
|
+
}
|
|
60
|
+
if let grandchildren = childrenMap[childPid] {
|
|
61
|
+
queue.append(contentsOf: grandchildren)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return result
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// BFS descendants filtered to interesting commands only.
|
|
68
|
+
func interestingDescendants(of pid: Int) -> [ProcessEntry] {
|
|
69
|
+
descendants(of: pid).filter { ProcessQuery.interestingCommands.contains($0.comm) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// MARK: - Enrichment
|
|
73
|
+
|
|
74
|
+
struct Enrichment {
|
|
75
|
+
let process: ProcessEntry
|
|
76
|
+
let tmuxSession: String?
|
|
77
|
+
let tmuxPaneId: String?
|
|
78
|
+
let windowId: UInt32?
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Walk ppid chain from a process upward until we find a tmux pane_pid.
|
|
82
|
+
/// Returns (sessionName, paneId) or nil.
|
|
83
|
+
func tmuxLinkage(for entry: ProcessEntry) -> (session: String, paneId: String)? {
|
|
84
|
+
let paneLookup = buildPaneLookup()
|
|
85
|
+
var current = entry.pid
|
|
86
|
+
// Walk up at most 10 hops (typically 2-3)
|
|
87
|
+
for _ in 0..<10 {
|
|
88
|
+
if let match = paneLookup[current] {
|
|
89
|
+
return match
|
|
90
|
+
}
|
|
91
|
+
guard let parent = processTable[current]?.ppid, parent != current, parent > 1 else {
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
current = parent
|
|
95
|
+
}
|
|
96
|
+
return nil
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Enrich a single process with tmux + window linkage.
|
|
100
|
+
func enrich(_ entry: ProcessEntry) -> Enrichment {
|
|
101
|
+
if let link = tmuxLinkage(for: entry) {
|
|
102
|
+
let win = DesktopModel.shared.windowForSession(link.session)
|
|
103
|
+
return Enrichment(
|
|
104
|
+
process: entry,
|
|
105
|
+
tmuxSession: link.session,
|
|
106
|
+
tmuxPaneId: link.paneId,
|
|
107
|
+
windowId: win?.wid
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
return Enrichment(process: entry, tmuxSession: nil, tmuxPaneId: nil, windowId: nil)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// Enrich all interesting processes.
|
|
114
|
+
func enrichedProcesses() -> [Enrichment] {
|
|
115
|
+
interesting.map { enrich($0) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// MARK: - Terminal Synthesis (on-demand)
|
|
119
|
+
|
|
120
|
+
/// Synthesize terminal instances on demand. Merges the current process table,
|
|
121
|
+
/// tmux sessions, terminal tabs (cached), and window list into a unified view.
|
|
122
|
+
/// Called by API endpoints — no background polling needed.
|
|
123
|
+
func synthesizeTerminals() -> [TerminalInstance] {
|
|
124
|
+
// Refresh tab cache if stale
|
|
125
|
+
let now = Date()
|
|
126
|
+
if now.timeIntervalSince(lastTabQueryTime) >= Self.tabCacheTTL {
|
|
127
|
+
cachedTerminalTabs = TerminalQuery.queryAll()
|
|
128
|
+
lastTabQueryTime = now
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return TerminalSynthesizer.synthesize(
|
|
132
|
+
processTable: processTable,
|
|
133
|
+
interesting: interesting,
|
|
134
|
+
tmuxSessions: TmuxModel.shared.sessions,
|
|
135
|
+
terminalTabs: cachedTerminalTabs,
|
|
136
|
+
windows: DesktopModel.shared.windows
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// Force-refresh the terminal tab cache (e.g. on first query or explicit refresh).
|
|
141
|
+
func refreshTerminalTabs() {
|
|
142
|
+
cachedTerminalTabs = TerminalQuery.queryAll()
|
|
143
|
+
lastTabQueryTime = Date()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// MARK: - Polling (runs on pollQueue)
|
|
147
|
+
|
|
148
|
+
func poll() {
|
|
149
|
+
// 1. Full process snapshot
|
|
150
|
+
var table = ProcessQuery.snapshot()
|
|
151
|
+
|
|
152
|
+
// 2. Build parent → children map
|
|
153
|
+
var children: [Int: [Int]] = [:]
|
|
154
|
+
for (pid, entry) in table {
|
|
155
|
+
children[entry.ppid, default: []].append(pid)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 3. Filter interesting, batch-resolve CWDs
|
|
159
|
+
let interestingEntries = ProcessQuery.filterInteresting(table)
|
|
160
|
+
let pids = interestingEntries.map(\.pid)
|
|
161
|
+
let cwds = ProcessQuery.batchCWD(pids: pids)
|
|
162
|
+
|
|
163
|
+
// 4. Merge CWDs back into table
|
|
164
|
+
for (pid, cwd) in cwds {
|
|
165
|
+
table[pid]?.cwd = cwd
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let freshInteresting = pids.compactMap { table[$0] }
|
|
169
|
+
let freshPidSet = Set(pids)
|
|
170
|
+
|
|
171
|
+
// 5. Detect change
|
|
172
|
+
let changed = freshPidSet != lastInterestingPids
|
|
173
|
+
|
|
174
|
+
DispatchQueue.main.async {
|
|
175
|
+
self.processTable = table
|
|
176
|
+
self.childrenMap = children
|
|
177
|
+
self.interesting = freshInteresting
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
lastInterestingPids = freshPidSet
|
|
181
|
+
|
|
182
|
+
if changed {
|
|
183
|
+
EventBus.shared.post(.processesChanged(interesting: Array(freshPidSet)))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// MARK: - Private
|
|
188
|
+
|
|
189
|
+
/// Build [pane_pid: (sessionName, paneId)] from current TmuxModel state.
|
|
190
|
+
private func buildPaneLookup() -> [Int: (session: String, paneId: String)] {
|
|
191
|
+
var lookup: [Int: (session: String, paneId: String)] = [:]
|
|
192
|
+
for session in TmuxModel.shared.sessions {
|
|
193
|
+
for pane in session.panes {
|
|
194
|
+
lookup[pane.pid] = (session: session.name, paneId: pane.id)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return lookup
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// MARK: - Data Models
|
|
4
|
+
|
|
5
|
+
struct ProcessEntry {
|
|
6
|
+
let pid: Int
|
|
7
|
+
let ppid: Int
|
|
8
|
+
let pgid: Int
|
|
9
|
+
let tty: String // "ttys003" or "??"
|
|
10
|
+
let comm: String // basename, e.g. "node"
|
|
11
|
+
let args: String // full command line
|
|
12
|
+
var cwd: String? // filled by batchCWD
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// MARK: - Query
|
|
16
|
+
|
|
17
|
+
enum ProcessQuery {
|
|
18
|
+
|
|
19
|
+
/// Process names we care about for developer workspace enrichment
|
|
20
|
+
static let interestingCommands: Set<String> = [
|
|
21
|
+
"claude", "node", "bun", "deno", "python", "python3",
|
|
22
|
+
"ruby", "go", "cargo", "nvim", "vim", "npm", "npx",
|
|
23
|
+
"pnpm", "swift", "make", "git"
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
/// Snapshot the full process table in a single `ps` call.
|
|
27
|
+
/// Returns [pid: ProcessEntry].
|
|
28
|
+
static func snapshot() -> [Int: ProcessEntry] {
|
|
29
|
+
let raw = shell([
|
|
30
|
+
"/bin/ps", "-eo", "pid,ppid,pgid,tty,comm,args"
|
|
31
|
+
])
|
|
32
|
+
guard !raw.isEmpty else { return [:] }
|
|
33
|
+
|
|
34
|
+
var table: [Int: ProcessEntry] = [:]
|
|
35
|
+
let lines = raw.split(separator: "\n", omittingEmptySubsequences: true)
|
|
36
|
+
|
|
37
|
+
for line in lines.dropFirst() { // skip header
|
|
38
|
+
let str = String(line)
|
|
39
|
+
// Columns are whitespace-separated; args can contain spaces.
|
|
40
|
+
// Format: " PID PPID PGID TTY COMM ARGS"
|
|
41
|
+
let trimmed = str.trimmingCharacters(in: .whitespaces)
|
|
42
|
+
let parts = trimmed.split(separator: " ", maxSplits: 5, omittingEmptySubsequences: true)
|
|
43
|
+
guard parts.count >= 6 else { continue }
|
|
44
|
+
|
|
45
|
+
guard let pid = Int(parts[0]),
|
|
46
|
+
let ppid = Int(parts[1]),
|
|
47
|
+
let pgid = Int(parts[2]) else { continue }
|
|
48
|
+
|
|
49
|
+
let tty = String(parts[3])
|
|
50
|
+
let commFull = String(parts[4])
|
|
51
|
+
let args = String(parts[5])
|
|
52
|
+
|
|
53
|
+
// comm from ps is the full path; take basename
|
|
54
|
+
let comm = (commFull as NSString).lastPathComponent
|
|
55
|
+
|
|
56
|
+
table[pid] = ProcessEntry(
|
|
57
|
+
pid: pid, ppid: ppid, pgid: pgid,
|
|
58
|
+
tty: tty, comm: comm, args: args, cwd: nil
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return table
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Batch-resolve working directories for a set of PIDs via a single `lsof` call.
|
|
66
|
+
/// Returns [pid: cwdPath].
|
|
67
|
+
static func batchCWD(pids: [Int]) -> [Int: String] {
|
|
68
|
+
guard !pids.isEmpty else { return [:] }
|
|
69
|
+
|
|
70
|
+
let pidList = pids.map(String.init).joined(separator: ",")
|
|
71
|
+
let raw = shell([
|
|
72
|
+
"/usr/sbin/lsof", "-a", "-d", "cwd", "-p", pidList, "-Fn"
|
|
73
|
+
])
|
|
74
|
+
guard !raw.isEmpty else { return [:] }
|
|
75
|
+
|
|
76
|
+
var result: [Int: String] = [:]
|
|
77
|
+
var currentPid: Int?
|
|
78
|
+
|
|
79
|
+
for line in raw.split(separator: "\n", omittingEmptySubsequences: true) {
|
|
80
|
+
let s = String(line)
|
|
81
|
+
if s.hasPrefix("p") {
|
|
82
|
+
currentPid = Int(s.dropFirst())
|
|
83
|
+
} else if s.hasPrefix("n"), let pid = currentPid {
|
|
84
|
+
result[pid] = String(s.dropFirst())
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return result
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Filter a process table down to interesting developer processes.
|
|
92
|
+
static func filterInteresting(_ table: [Int: ProcessEntry]) -> [ProcessEntry] {
|
|
93
|
+
table.values.filter { interestingCommands.contains($0.comm) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// MARK: - Shell helper
|
|
97
|
+
|
|
98
|
+
/// Run a command and capture stdout using posix_spawn + waitpid.
|
|
99
|
+
/// Avoids Process/NSTask's waitUntilExit() which deadlocks on macOS 26
|
|
100
|
+
/// when called from GUI apps (CFRunLoop issue).
|
|
101
|
+
static func shell(_ args: [String]) -> String {
|
|
102
|
+
// Set up stdout pipe
|
|
103
|
+
var pipeFds: [Int32] = [0, 0]
|
|
104
|
+
guard pipe(&pipeFds) == 0 else { return "" }
|
|
105
|
+
|
|
106
|
+
// File actions: stdout → write end of pipe, stderr → /dev/null
|
|
107
|
+
var fileActions: posix_spawn_file_actions_t?
|
|
108
|
+
posix_spawn_file_actions_init(&fileActions)
|
|
109
|
+
posix_spawn_file_actions_adddup2(&fileActions, pipeFds[1], STDOUT_FILENO)
|
|
110
|
+
posix_spawn_file_actions_addopen(&fileActions, STDERR_FILENO, "/dev/null", O_WRONLY, 0)
|
|
111
|
+
posix_spawn_file_actions_addclose(&fileActions, pipeFds[0])
|
|
112
|
+
posix_spawn_file_actions_addclose(&fileActions, pipeFds[1])
|
|
113
|
+
|
|
114
|
+
// Build C strings
|
|
115
|
+
let cPath = args[0]
|
|
116
|
+
let cArgs = args.map { strdup($0) } + [nil]
|
|
117
|
+
defer { cArgs.compactMap({ $0 }).forEach { free($0) } }
|
|
118
|
+
|
|
119
|
+
var pid: pid_t = 0
|
|
120
|
+
let spawnResult = cPath.withCString { path in
|
|
121
|
+
posix_spawn(&pid, path, &fileActions, nil, cArgs, environ)
|
|
122
|
+
}
|
|
123
|
+
posix_spawn_file_actions_destroy(&fileActions)
|
|
124
|
+
|
|
125
|
+
// Close write end in parent
|
|
126
|
+
close(pipeFds[1])
|
|
127
|
+
|
|
128
|
+
guard spawnResult == 0 else {
|
|
129
|
+
close(pipeFds[0])
|
|
130
|
+
return ""
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Read all stdout
|
|
134
|
+
var data = Data()
|
|
135
|
+
let bufSize = 65536
|
|
136
|
+
var buf = [UInt8](repeating: 0, count: bufSize)
|
|
137
|
+
while true {
|
|
138
|
+
let n = read(pipeFds[0], &buf, bufSize)
|
|
139
|
+
if n <= 0 { break }
|
|
140
|
+
data.append(buf, count: n)
|
|
141
|
+
}
|
|
142
|
+
close(pipeFds[0])
|
|
143
|
+
|
|
144
|
+
// Wait for child
|
|
145
|
+
var status: Int32 = 0
|
|
146
|
+
waitpid(pid, &status, 0)
|
|
147
|
+
|
|
148
|
+
guard status == 0 else { return "" }
|
|
149
|
+
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
struct Project: Identifiable {
|
|
5
|
+
let id: String
|
|
6
|
+
let path: String
|
|
7
|
+
let name: String
|
|
8
|
+
let devCommand: String?
|
|
9
|
+
let packageManager: String?
|
|
10
|
+
let hasConfig: Bool
|
|
11
|
+
let paneCount: Int
|
|
12
|
+
let paneNames: [String]
|
|
13
|
+
let paneSummary: String
|
|
14
|
+
var isRunning: Bool
|
|
15
|
+
|
|
16
|
+
/// Unique session name: basename-{6-char SHA256 hash of full path}
|
|
17
|
+
/// Must match the JS `toSessionName()` in lattices.js exactly
|
|
18
|
+
var sessionName: String {
|
|
19
|
+
let base = name.replacingOccurrences(
|
|
20
|
+
of: "[^a-zA-Z0-9_-]",
|
|
21
|
+
with: "-",
|
|
22
|
+
options: .regularExpression
|
|
23
|
+
)
|
|
24
|
+
let hash = SHA256.hash(data: Data(path.utf8))
|
|
25
|
+
let short = hash.prefix(3).map { String(format: "%02x", $0) }.joined()
|
|
26
|
+
return "\(base)-\(short)"
|
|
27
|
+
}
|
|
28
|
+
}
|