@hamedb89/localghost 0.1.8 → 0.1.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 +158 -27
- package/apps/macos-widget/LocalghostWidget.entitlements +12 -0
- package/apps/macos-widget/LocalghostWidget.swift +701 -48
- package/apps/macos-widget/Resources/localghost-logo-source.png +0 -0
- package/apps/macos-widget/Resources/localghost-widget-ui-reference.png +0 -0
- package/apps/macos-widget/Shared/LocalghostWidgetSnapshot.swift +50 -0
- package/apps/macos-widget/WidgetExtension/LocalghostDesktopWidget.swift +176 -0
- package/apps/macos-widget/WidgetExtension/LocalghostDesktopWidgetExtension.entitlements +12 -0
- package/apps/macos-widget/build.sh +8 -1
- package/apps/macos-widget/project.yml +40 -0
- package/assets/ghost-tunnel-app-icon.png +0 -0
- package/assets/ghost-tunnel-portal.png +0 -0
- package/assets/ghost-tunnel-wordmark.png +0 -0
- package/dist/cli.js +524 -63
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +130 -5
- package/dist/index.js +761 -31
- package/dist/index.js.map +1 -1
- package/dist/tunnel-DzfLXZ8O.d.ts +126 -0
- package/dist/vite.d.ts +3 -1
- package/dist/vite.js +655 -36
- package/dist/vite.js.map +1 -1
- package/docs/flows.md +19 -0
- package/docs/ghost-tunnel.md +249 -0
- package/docs/github.md +6 -6
- package/docs/localghost.1.md +10 -4
- package/docs/macos-widget.md +68 -6
- package/package.json +6 -2
- package/dist/config-Cde1Bich.d.ts +0 -31
|
@@ -1,19 +1,54 @@
|
|
|
1
1
|
import AppKit
|
|
2
|
+
import Darwin
|
|
2
3
|
import Foundation
|
|
3
4
|
|
|
4
|
-
struct
|
|
5
|
-
let
|
|
6
|
-
let
|
|
5
|
+
struct LocalghostActivityFile: Decodable {
|
|
6
|
+
let runs: [LocalghostActivityRun]?
|
|
7
|
+
let setups: [LocalghostActivitySetup]?
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
struct LocalghostRun: Decodable {
|
|
11
|
+
let mode: String
|
|
12
|
+
let pid: Int?
|
|
13
|
+
let cwd: String
|
|
14
|
+
let projectName: String
|
|
15
|
+
let running: Bool?
|
|
16
|
+
let startedAt: String?
|
|
17
|
+
let updatedAt: String?
|
|
18
|
+
let childCommand: [String]?
|
|
19
|
+
let routes: [LocalghostRoute]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
struct LocalghostActivityRun: Decodable {
|
|
23
|
+
let id: String
|
|
10
24
|
let mode: String
|
|
11
25
|
let pid: Int
|
|
12
26
|
let cwd: String
|
|
13
27
|
let projectName: String
|
|
14
28
|
let startedAt: String
|
|
29
|
+
let updatedAt: String
|
|
30
|
+
let configPath: String?
|
|
31
|
+
let caddyfilePath: String?
|
|
15
32
|
let childCommand: [String]?
|
|
16
|
-
let
|
|
33
|
+
let https: Bool?
|
|
34
|
+
let entries: [LocalghostEntry]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
struct LocalghostActivitySetup: Decodable {
|
|
38
|
+
let id: String
|
|
39
|
+
let cwd: String
|
|
40
|
+
let projectName: String
|
|
41
|
+
let updatedAt: String
|
|
42
|
+
let configPath: String?
|
|
43
|
+
let caddyfilePath: String?
|
|
44
|
+
let https: Bool?
|
|
45
|
+
let entries: [LocalghostEntry]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
struct LocalghostEntry: Decodable {
|
|
49
|
+
let host: String
|
|
50
|
+
let port: Int
|
|
51
|
+
let target: String?
|
|
17
52
|
}
|
|
18
53
|
|
|
19
54
|
struct LocalghostRoute: Decodable {
|
|
@@ -23,15 +58,38 @@ struct LocalghostRoute: Decodable {
|
|
|
23
58
|
let listening: Bool
|
|
24
59
|
}
|
|
25
60
|
|
|
61
|
+
struct LocalghostEndpointLogEntry {
|
|
62
|
+
let timestamp: Date
|
|
63
|
+
let message: String
|
|
64
|
+
let active: Bool
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
extension LocalghostRun {
|
|
68
|
+
var isRunning: Bool {
|
|
69
|
+
running ?? (pid != nil)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
26
73
|
final class LocalghostWidgetApp: NSObject, NSApplicationDelegate {
|
|
27
|
-
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.
|
|
74
|
+
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
|
28
75
|
private var timer: Timer?
|
|
29
76
|
private var latestRuns: [LocalghostRun] = []
|
|
30
77
|
private var latestError: String?
|
|
78
|
+
private var endpointLog: [LocalghostEndpointLogEntry] = []
|
|
79
|
+
private var desktopPanel: NSPanel?
|
|
80
|
+
private var desktopView: LocalghostDesktopWidgetView?
|
|
31
81
|
|
|
32
82
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
33
83
|
NSApp.setActivationPolicy(.accessory)
|
|
34
|
-
|
|
84
|
+
NSApp.applicationIconImage = LocalghostAssets.whiteLogo
|
|
85
|
+
if let button = statusItem.button {
|
|
86
|
+
button.image = LocalghostAssets.templateLogo
|
|
87
|
+
button.imagePosition = .imageOnly
|
|
88
|
+
button.imageScaling = .scaleProportionallyUpOrDown
|
|
89
|
+
button.title = ""
|
|
90
|
+
button.toolTip = "Localghost"
|
|
91
|
+
}
|
|
92
|
+
showDesktopWidget()
|
|
35
93
|
rebuildMenu()
|
|
36
94
|
refresh()
|
|
37
95
|
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
|
|
@@ -45,14 +103,30 @@ final class LocalghostWidgetApp: NSObject, NSApplicationDelegate {
|
|
|
45
103
|
DispatchQueue.main.async {
|
|
46
104
|
switch result {
|
|
47
105
|
case .success(let runs):
|
|
106
|
+
self?.endpointLog = Self.updatedEndpointLog(
|
|
107
|
+
previousRuns: self?.latestRuns ?? [],
|
|
108
|
+
newRuns: runs,
|
|
109
|
+
currentLog: self?.endpointLog ?? []
|
|
110
|
+
)
|
|
48
111
|
self?.latestRuns = runs
|
|
49
112
|
self?.latestError = nil
|
|
113
|
+
Self.publishWidgetSnapshot(runs)
|
|
50
114
|
case .failure(let error):
|
|
115
|
+
self?.endpointLog = Self.appendingEndpointLog(
|
|
116
|
+
message: "Status read failed: \(error.localizedDescription)",
|
|
117
|
+
active: false,
|
|
118
|
+
to: self?.endpointLog ?? []
|
|
119
|
+
)
|
|
51
120
|
self?.latestRuns = []
|
|
52
121
|
self?.latestError = error.localizedDescription
|
|
53
122
|
}
|
|
54
123
|
|
|
55
124
|
self?.updateStatusTitle()
|
|
125
|
+
self?.desktopView?.update(
|
|
126
|
+
runs: self?.latestRuns ?? [],
|
|
127
|
+
errorMessage: self?.latestError,
|
|
128
|
+
endpointLog: self?.endpointLog ?? []
|
|
129
|
+
)
|
|
56
130
|
self?.rebuildMenu()
|
|
57
131
|
}
|
|
58
132
|
}
|
|
@@ -60,11 +134,12 @@ final class LocalghostWidgetApp: NSObject, NSApplicationDelegate {
|
|
|
60
134
|
|
|
61
135
|
private func updateStatusTitle() {
|
|
62
136
|
if latestError != nil {
|
|
63
|
-
statusItem.button?.
|
|
137
|
+
statusItem.button?.toolTip = "Localghost status unavailable"
|
|
64
138
|
return
|
|
65
139
|
}
|
|
66
140
|
|
|
67
|
-
|
|
141
|
+
let runningCount = latestRuns.filter { $0.isRunning }.count
|
|
142
|
+
statusItem.button?.toolTip = "Localghost: \(runningCount) running, \(latestRuns.count) setup"
|
|
68
143
|
}
|
|
69
144
|
|
|
70
145
|
private func rebuildMenu() {
|
|
@@ -79,11 +154,12 @@ final class LocalghostWidgetApp: NSObject, NSApplicationDelegate {
|
|
|
79
154
|
detail.isEnabled = false
|
|
80
155
|
menu.addItem(detail)
|
|
81
156
|
} else if latestRuns.isEmpty {
|
|
82
|
-
let item = NSMenuItem(title: "No Localghost
|
|
157
|
+
let item = NSMenuItem(title: "No Localghost setups found", action: nil, keyEquivalent: "")
|
|
83
158
|
item.isEnabled = false
|
|
84
159
|
menu.addItem(item)
|
|
85
160
|
} else {
|
|
86
|
-
let
|
|
161
|
+
let runningCount = latestRuns.filter { $0.isRunning }.count
|
|
162
|
+
let title = "\(runningCount) running, \(latestRuns.count) setup"
|
|
87
163
|
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
88
164
|
item.isEnabled = false
|
|
89
165
|
menu.addItem(item)
|
|
@@ -94,8 +170,15 @@ final class LocalghostWidgetApp: NSObject, NSApplicationDelegate {
|
|
|
94
170
|
addRun(run, to: menu)
|
|
95
171
|
menu.addItem(.separator())
|
|
96
172
|
}
|
|
173
|
+
|
|
174
|
+
addEndpointLog(to: menu)
|
|
97
175
|
}
|
|
98
176
|
|
|
177
|
+
let desktopTitle = desktopPanel?.isVisible == true ? "Hide Desktop Widget" : "Show Desktop Widget"
|
|
178
|
+
let desktopItem = NSMenuItem(title: desktopTitle, action: #selector(toggleDesktopWidget), keyEquivalent: "w")
|
|
179
|
+
desktopItem.target = self
|
|
180
|
+
menu.addItem(desktopItem)
|
|
181
|
+
|
|
99
182
|
let refreshItem = NSMenuItem(title: "Refresh", action: #selector(refreshFromMenu), keyEquivalent: "r")
|
|
100
183
|
refreshItem.target = self
|
|
101
184
|
menu.addItem(refreshItem)
|
|
@@ -109,8 +192,9 @@ final class LocalghostWidgetApp: NSObject, NSApplicationDelegate {
|
|
|
109
192
|
|
|
110
193
|
private func addRun(_ run: LocalghostRun, to menu: NSMenu) {
|
|
111
194
|
let command = run.childCommand?.joined(separator: " ")
|
|
112
|
-
let mode = command.map { "\(run.mode): \($0)" } ?? run.mode
|
|
113
|
-
let
|
|
195
|
+
let mode = command.map { "\(run.mode): \($0)" } ?? (run.mode == "setup" ? "" : run.mode)
|
|
196
|
+
let state = run.isRunning ? "running" : "setup"
|
|
197
|
+
let title = mode.isEmpty ? "\(run.projectName) \(state)" : "\(run.projectName) \(state) \(mode)"
|
|
114
198
|
let projectItem = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
115
199
|
projectItem.isEnabled = false
|
|
116
200
|
menu.addItem(projectItem)
|
|
@@ -119,9 +203,11 @@ final class LocalghostWidgetApp: NSObject, NSApplicationDelegate {
|
|
|
119
203
|
cwdItem.isEnabled = false
|
|
120
204
|
menu.addItem(cwdItem)
|
|
121
205
|
|
|
122
|
-
let
|
|
123
|
-
|
|
124
|
-
|
|
206
|
+
if let pid = run.pid {
|
|
207
|
+
let pidItem = NSMenuItem(title: " pid \(pid)", action: nil, keyEquivalent: "")
|
|
208
|
+
pidItem.isEnabled = false
|
|
209
|
+
menu.addItem(pidItem)
|
|
210
|
+
}
|
|
125
211
|
|
|
126
212
|
for route in run.routes {
|
|
127
213
|
let state = route.listening ? "listening" : "not listening"
|
|
@@ -131,67 +217,634 @@ final class LocalghostWidgetApp: NSObject, NSApplicationDelegate {
|
|
|
131
217
|
}
|
|
132
218
|
}
|
|
133
219
|
|
|
220
|
+
private func addEndpointLog(to menu: NSMenu) {
|
|
221
|
+
guard !endpointLog.isEmpty else { return }
|
|
222
|
+
|
|
223
|
+
let title = NSMenuItem(title: "Endpoint log", action: nil, keyEquivalent: "")
|
|
224
|
+
title.isEnabled = false
|
|
225
|
+
menu.addItem(title)
|
|
226
|
+
|
|
227
|
+
for entry in endpointLog.prefix(4) {
|
|
228
|
+
let time = Self.logTimeFormatter.string(from: entry.timestamp)
|
|
229
|
+
let item = NSMenuItem(title: " \(time) \(entry.message)", action: nil, keyEquivalent: "")
|
|
230
|
+
item.isEnabled = false
|
|
231
|
+
menu.addItem(item)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
menu.addItem(.separator())
|
|
235
|
+
}
|
|
236
|
+
|
|
134
237
|
@objc private func refreshFromMenu() {
|
|
135
238
|
refresh()
|
|
136
239
|
}
|
|
137
240
|
|
|
241
|
+
@objc private func toggleDesktopWidget() {
|
|
242
|
+
if desktopPanel?.isVisible == true {
|
|
243
|
+
desktopPanel?.orderOut(nil)
|
|
244
|
+
} else {
|
|
245
|
+
showDesktopWidget()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
rebuildMenu()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private func showDesktopWidget() {
|
|
252
|
+
if desktopPanel == nil {
|
|
253
|
+
let widgetFrame = NSRect(x: 0, y: 0, width: 380, height: 384)
|
|
254
|
+
let glassView = NSVisualEffectView(frame: widgetFrame)
|
|
255
|
+
glassView.material = .hudWindow
|
|
256
|
+
glassView.blendingMode = .behindWindow
|
|
257
|
+
glassView.state = .active
|
|
258
|
+
glassView.wantsLayer = true
|
|
259
|
+
glassView.layer?.cornerRadius = 34
|
|
260
|
+
glassView.layer?.masksToBounds = true
|
|
261
|
+
|
|
262
|
+
let view = LocalghostDesktopWidgetView(frame: widgetFrame)
|
|
263
|
+
view.autoresizingMask = [.width, .height]
|
|
264
|
+
view.wantsLayer = true
|
|
265
|
+
view.layer?.backgroundColor = NSColor.clear.cgColor
|
|
266
|
+
view.update(runs: latestRuns, errorMessage: latestError, endpointLog: endpointLog)
|
|
267
|
+
view.openFirstRoute = { [weak self] in
|
|
268
|
+
self?.openFirstRoute()
|
|
269
|
+
}
|
|
270
|
+
glassView.addSubview(view)
|
|
271
|
+
|
|
272
|
+
let panel = NSPanel(
|
|
273
|
+
contentRect: widgetFrame,
|
|
274
|
+
styleMask: [.borderless, .nonactivatingPanel],
|
|
275
|
+
backing: .buffered,
|
|
276
|
+
defer: false
|
|
277
|
+
)
|
|
278
|
+
panel.contentView = glassView
|
|
279
|
+
panel.backgroundColor = .clear
|
|
280
|
+
panel.isOpaque = false
|
|
281
|
+
panel.hasShadow = false
|
|
282
|
+
panel.level = .normal
|
|
283
|
+
panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
|
284
|
+
panel.isMovableByWindowBackground = true
|
|
285
|
+
panel.hidesOnDeactivate = false
|
|
286
|
+
|
|
287
|
+
desktopView = view
|
|
288
|
+
desktopPanel = panel
|
|
289
|
+
panel.center()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
desktopPanel?.orderFrontRegardless()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private func openFirstRoute() {
|
|
296
|
+
guard let route = latestRuns.flatMap(\.routes).first else { return }
|
|
297
|
+
NSWorkspace.shared.open(URL(string: "http://\(route.host)")!)
|
|
298
|
+
}
|
|
299
|
+
|
|
138
300
|
@objc private func quit() {
|
|
139
301
|
NSApp.terminate(nil)
|
|
140
302
|
}
|
|
141
303
|
|
|
142
304
|
private static func loadRuns() -> Result<[LocalghostRun], Error> {
|
|
143
305
|
do {
|
|
144
|
-
|
|
145
|
-
let response = try JSONDecoder().decode(LocalghostPsResponse.self, from: output)
|
|
146
|
-
return .success(response.runs)
|
|
306
|
+
return .success(try loadActivityInstances())
|
|
147
307
|
} catch {
|
|
148
308
|
return .failure(error)
|
|
149
309
|
}
|
|
150
310
|
}
|
|
151
311
|
|
|
152
|
-
private static func
|
|
153
|
-
let
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
312
|
+
private static func publishWidgetSnapshot(_ runs: [LocalghostRun]) {
|
|
313
|
+
let snapshot = LocalghostWidgetSnapshot(
|
|
314
|
+
generatedAt: ISO8601DateFormatter().string(from: Date()),
|
|
315
|
+
instances: runs.map { run in
|
|
316
|
+
LocalghostWidgetInstance(
|
|
317
|
+
id: "\(run.projectName):\(run.cwd):\(run.mode)",
|
|
318
|
+
projectName: run.projectName,
|
|
319
|
+
cwd: run.cwd,
|
|
320
|
+
running: run.isRunning,
|
|
321
|
+
mode: run.mode,
|
|
322
|
+
routes: run.routes.map { route in
|
|
323
|
+
LocalghostWidgetRoute(host: route.host, port: route.port, listening: route.listening)
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
try? LocalghostWidgetSharedStore.writeSnapshot(snapshot)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private static func loadActivityInstances() throws -> [LocalghostRun] {
|
|
333
|
+
let path = activityPath()
|
|
334
|
+
if !FileManager.default.fileExists(atPath: path) {
|
|
335
|
+
return []
|
|
336
|
+
}
|
|
159
337
|
|
|
160
|
-
let
|
|
161
|
-
let
|
|
162
|
-
|
|
163
|
-
|
|
338
|
+
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
|
339
|
+
let activity = try JSONDecoder().decode(LocalghostActivityFile.self, from: data)
|
|
340
|
+
let activeRuns = (activity.runs ?? []).filter { isProcessRunning($0.pid) }
|
|
341
|
+
let runBySetup = Dictionary(uniqueKeysWithValues: activeRuns.map { (activityKey(projectName: $0.projectName, cwd: $0.cwd, configPath: $0.configPath), $0) })
|
|
342
|
+
var instances: [LocalghostRun] = []
|
|
343
|
+
var consumedRunKeys = Set<String>()
|
|
344
|
+
|
|
345
|
+
for setup in activity.setups ?? [] {
|
|
346
|
+
let key = activityKey(projectName: setup.projectName, cwd: setup.cwd, configPath: setup.configPath)
|
|
347
|
+
if let run = runBySetup[key] {
|
|
348
|
+
consumedRunKeys.insert(key)
|
|
349
|
+
instances.append(instance(from: run, setup: setup))
|
|
350
|
+
} else {
|
|
351
|
+
instances.append(instance(from: setup))
|
|
352
|
+
}
|
|
353
|
+
}
|
|
164
354
|
|
|
165
|
-
|
|
166
|
-
|
|
355
|
+
for run in activeRuns {
|
|
356
|
+
let key = activityKey(projectName: run.projectName, cwd: run.cwd, configPath: run.configPath)
|
|
357
|
+
if !consumedRunKeys.contains(key) {
|
|
358
|
+
instances.append(instance(from: run, setup: nil))
|
|
359
|
+
}
|
|
360
|
+
}
|
|
167
361
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return
|
|
362
|
+
return instances.sorted {
|
|
363
|
+
if $0.isRunning != $1.isRunning { return $0.isRunning && !$1.isRunning }
|
|
364
|
+
return $0.projectName.localizedCaseInsensitiveCompare($1.projectName) == .orderedAscending
|
|
171
365
|
}
|
|
366
|
+
}
|
|
172
367
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
368
|
+
private static func instance(from setup: LocalghostActivitySetup) -> LocalghostRun {
|
|
369
|
+
LocalghostRun(
|
|
370
|
+
mode: "setup",
|
|
371
|
+
pid: nil,
|
|
372
|
+
cwd: setup.cwd,
|
|
373
|
+
projectName: setup.projectName,
|
|
374
|
+
running: false,
|
|
375
|
+
startedAt: nil,
|
|
376
|
+
updatedAt: setup.updatedAt,
|
|
377
|
+
childCommand: nil,
|
|
378
|
+
routes: setup.entries.map { route(from: $0, forceListening: false) }
|
|
379
|
+
)
|
|
176
380
|
}
|
|
177
381
|
|
|
178
|
-
private static func
|
|
179
|
-
|
|
382
|
+
private static func instance(from run: LocalghostActivityRun, setup: LocalghostActivitySetup?) -> LocalghostRun {
|
|
383
|
+
LocalghostRun(
|
|
384
|
+
mode: run.mode,
|
|
385
|
+
pid: run.pid,
|
|
386
|
+
cwd: run.cwd,
|
|
387
|
+
projectName: run.projectName,
|
|
388
|
+
running: true,
|
|
389
|
+
startedAt: run.startedAt,
|
|
390
|
+
updatedAt: setup?.updatedAt ?? run.updatedAt,
|
|
391
|
+
childCommand: run.childCommand,
|
|
392
|
+
routes: run.entries.map { route(from: $0, forceListening: true) }
|
|
393
|
+
)
|
|
180
394
|
}
|
|
181
395
|
|
|
182
|
-
private static func
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
396
|
+
private static func route(from entry: LocalghostEntry, forceListening: Bool) -> LocalghostRoute {
|
|
397
|
+
LocalghostRoute(
|
|
398
|
+
host: entry.host,
|
|
399
|
+
port: entry.port,
|
|
400
|
+
target: entry.target ?? "127.0.0.1:\(entry.port)",
|
|
401
|
+
listening: forceListening || isPortListening(entry.port)
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private static func updatedEndpointLog(
|
|
406
|
+
previousRuns: [LocalghostRun],
|
|
407
|
+
newRuns: [LocalghostRun],
|
|
408
|
+
currentLog: [LocalghostEndpointLogEntry]
|
|
409
|
+
) -> [LocalghostEndpointLogEntry] {
|
|
410
|
+
let previousRoutes = routeStates(from: previousRuns)
|
|
411
|
+
let newRoutes = routeStates(from: newRuns)
|
|
412
|
+
var log = currentLog
|
|
413
|
+
|
|
414
|
+
for key in newRoutes.keys.sorted() {
|
|
415
|
+
guard let route = newRoutes[key] else { continue }
|
|
416
|
+
let previous = previousRoutes[key]
|
|
417
|
+
|
|
418
|
+
if previous == nil {
|
|
419
|
+
log = appendingEndpointLog(
|
|
420
|
+
message: "\(route.host) \(route.listening ? "connected" : "waiting") on \(route.target)",
|
|
421
|
+
active: route.listening,
|
|
422
|
+
to: log
|
|
423
|
+
)
|
|
424
|
+
} else if previous?.listening != route.listening {
|
|
425
|
+
log = appendingEndpointLog(
|
|
426
|
+
message: "\(route.host) \(route.listening ? "connected" : "lost connection")",
|
|
427
|
+
active: route.listening,
|
|
428
|
+
to: log
|
|
429
|
+
)
|
|
430
|
+
}
|
|
186
431
|
}
|
|
187
432
|
|
|
188
|
-
|
|
433
|
+
for key in previousRoutes.keys.sorted() where newRoutes[key] == nil {
|
|
434
|
+
guard let route = previousRoutes[key] else { continue }
|
|
435
|
+
log = appendingEndpointLog(message: "\(route.host) stopped", active: false, to: log)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return Array(log.prefix(8))
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private static func routeStates(from runs: [LocalghostRun]) -> [String: LocalghostRoute] {
|
|
442
|
+
var states: [String: LocalghostRoute] = [:]
|
|
443
|
+
for route in runs.flatMap(\.routes) {
|
|
444
|
+
states["\(route.host):\(route.port):\(route.target)"] = route
|
|
445
|
+
}
|
|
446
|
+
return states
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private static func appendingEndpointLog(
|
|
450
|
+
message: String,
|
|
451
|
+
active: Bool,
|
|
452
|
+
to log: [LocalghostEndpointLogEntry]
|
|
453
|
+
) -> [LocalghostEndpointLogEntry] {
|
|
454
|
+
let duplicate = log.first?.message == message && Date().timeIntervalSince(log.first?.timestamp ?? .distantPast) < 2
|
|
455
|
+
if duplicate { return log }
|
|
456
|
+
|
|
457
|
+
return [LocalghostEndpointLogEntry(timestamp: Date(), message: message, active: active)] + log
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private static func activityKey(projectName: String, cwd: String, configPath: String?) -> String {
|
|
461
|
+
"\(projectName):\(cwd):\(configPath ?? "")"
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private static func activityPath() -> String {
|
|
465
|
+
let environment = ProcessInfo.processInfo.environment
|
|
466
|
+
if let path = environment["LOCALGHOST_ACTIVITY_PATH"], !path.isEmpty {
|
|
467
|
+
return path
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
let stateRoot = environment["XDG_STATE_HOME"] ?? "\(NSHomeDirectory())/.local/state"
|
|
471
|
+
return "\(stateRoot)/localghost/activity.json"
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private static let logTimeFormatter: DateFormatter = {
|
|
475
|
+
let formatter = DateFormatter()
|
|
476
|
+
formatter.dateFormat = "HH:mm:ss"
|
|
477
|
+
return formatter
|
|
478
|
+
}()
|
|
479
|
+
|
|
480
|
+
private static func isProcessRunning(_ pid: Int) -> Bool {
|
|
481
|
+
if pid < 1 { return false }
|
|
482
|
+
let result = Darwin.kill(pid_t(pid), 0)
|
|
483
|
+
return result == 0 || errno == EPERM
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private static func isPortListening(_ port: Int) -> Bool {
|
|
487
|
+
if port < 1 || port > 65535 { return false }
|
|
488
|
+
|
|
489
|
+
let descriptor = Darwin.socket(AF_INET, SOCK_STREAM, 0)
|
|
490
|
+
if descriptor < 0 { return false }
|
|
491
|
+
defer { Darwin.close(descriptor) }
|
|
492
|
+
|
|
493
|
+
var address = sockaddr_in()
|
|
494
|
+
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
|
495
|
+
address.sin_family = sa_family_t(AF_INET)
|
|
496
|
+
address.sin_port = UInt16(port).bigEndian
|
|
497
|
+
inet_pton(AF_INET, "127.0.0.1", &address.sin_addr)
|
|
498
|
+
|
|
499
|
+
let result = withUnsafePointer(to: &address) { pointer in
|
|
500
|
+
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in
|
|
501
|
+
Darwin.connect(descriptor, socketAddress, socklen_t(MemoryLayout<sockaddr_in>.size))
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return result == 0
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
final class LocalghostDesktopWidgetView: NSView {
|
|
510
|
+
var openFirstRoute: (() -> Void)?
|
|
511
|
+
private var runs: [LocalghostRun] = []
|
|
512
|
+
private var errorMessage: String?
|
|
513
|
+
private var endpointLog: [LocalghostEndpointLogEntry] = []
|
|
514
|
+
|
|
515
|
+
override var isFlipped: Bool { true }
|
|
516
|
+
|
|
517
|
+
func update(runs: [LocalghostRun], errorMessage: String?, endpointLog: [LocalghostEndpointLogEntry]) {
|
|
518
|
+
self.runs = runs
|
|
519
|
+
self.errorMessage = errorMessage
|
|
520
|
+
self.endpointLog = endpointLog
|
|
521
|
+
needsDisplay = true
|
|
189
522
|
}
|
|
190
523
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
524
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
525
|
+
super.draw(dirtyRect)
|
|
526
|
+
|
|
527
|
+
drawBackground()
|
|
528
|
+
drawHeader()
|
|
529
|
+
drawRoutes()
|
|
530
|
+
drawEndpointLog()
|
|
531
|
+
drawFooter()
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
override func mouseUp(with event: NSEvent) {
|
|
535
|
+
let point = convert(event.locationInWindow, from: nil)
|
|
536
|
+
if footerRect.contains(point) {
|
|
537
|
+
openFirstRoute?()
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private var allRoutes: [LocalghostRoute] {
|
|
542
|
+
runs.flatMap(\.routes)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private var onlineRoutes: [LocalghostRoute] {
|
|
546
|
+
allRoutes.filter(\.listening)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private var firstHost: String? {
|
|
550
|
+
allRoutes.first?.host
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private var footerRect: NSRect {
|
|
554
|
+
NSRect(x: 28, y: bounds.height - 36, width: bounds.width - 56, height: 24)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private func drawBackground() {
|
|
558
|
+
let rect = bounds.insetBy(dx: 6, dy: 6)
|
|
559
|
+
let path = NSBezierPath(roundedRect: rect, xRadius: 30, yRadius: 30)
|
|
560
|
+
|
|
561
|
+
NSColor(calibratedRed: 0.05, green: 0.06, blue: 0.18, alpha: 0.58).setFill()
|
|
562
|
+
path.fill()
|
|
563
|
+
|
|
564
|
+
let gradient = NSGradient(colors: [
|
|
565
|
+
NSColor(calibratedRed: 0.78, green: 0.72, blue: 1.0, alpha: 0.20),
|
|
566
|
+
NSColor(calibratedRed: 0.13, green: 0.16, blue: 0.40, alpha: 0.18),
|
|
567
|
+
NSColor(calibratedRed: 0.02, green: 0.03, blue: 0.10, alpha: 0.38)
|
|
568
|
+
])
|
|
569
|
+
gradient?.draw(in: path, angle: -35)
|
|
570
|
+
|
|
571
|
+
let sheen = NSBezierPath(roundedRect: rect.insetBy(dx: 1, dy: 1), xRadius: 29, yRadius: 29)
|
|
572
|
+
let sheenGradient = NSGradient(colors: [
|
|
573
|
+
NSColor(calibratedWhite: 1.0, alpha: 0.14),
|
|
574
|
+
NSColor(calibratedWhite: 1.0, alpha: 0.02)
|
|
575
|
+
])
|
|
576
|
+
sheenGradient?.draw(in: sheen, angle: 90)
|
|
577
|
+
|
|
578
|
+
NSColor(calibratedWhite: 1.0, alpha: 0.18).setStroke()
|
|
579
|
+
path.lineWidth = 1
|
|
580
|
+
path.stroke()
|
|
581
|
+
|
|
582
|
+
let innerPath = NSBezierPath(roundedRect: rect.insetBy(dx: 1.5, dy: 1.5), xRadius: 28, yRadius: 28)
|
|
583
|
+
NSColor(calibratedWhite: 0.0, alpha: 0.18).setStroke()
|
|
584
|
+
innerPath.lineWidth = 1
|
|
585
|
+
innerPath.stroke()
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private func drawHeader() {
|
|
589
|
+
drawText(">_", at: NSPoint(x: 30, y: 42), font: .monospacedSystemFont(ofSize: 24, weight: .bold), color: accent)
|
|
590
|
+
drawText("Localghost", at: NSPoint(x: 83, y: 45), font: .systemFont(ofSize: 24, weight: .bold), color: .white)
|
|
591
|
+
|
|
592
|
+
let status = statusText()
|
|
593
|
+
drawStatusPill(text: status, at: NSPoint(x: 30, y: 86))
|
|
594
|
+
|
|
595
|
+
drawDivider(y: 122)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private func drawRoutes() {
|
|
599
|
+
if let errorMessage {
|
|
600
|
+
drawText("Localghost unavailable", at: NSPoint(x: 30, y: 152), font: .systemFont(ofSize: 17, weight: .semibold), color: .white)
|
|
601
|
+
drawText(errorMessage, in: NSRect(x: 30, y: 180, width: bounds.width - 60, height: 46), font: .systemFont(ofSize: 13, weight: .regular), color: muted)
|
|
602
|
+
return
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
let routes = Array(allRoutes.prefix(3))
|
|
606
|
+
if routes.isEmpty {
|
|
607
|
+
drawEmptyState()
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
for (index, route) in routes.enumerated() {
|
|
612
|
+
drawRoute(route, index: index)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if allRoutes.count > routes.count {
|
|
616
|
+
drawText(
|
|
617
|
+
"+ \(allRoutes.count - routes.count) more",
|
|
618
|
+
at: NSPoint(x: 42, y: 256),
|
|
619
|
+
font: .systemFont(ofSize: 12, weight: .medium),
|
|
620
|
+
color: muted
|
|
621
|
+
)
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private func drawFooter() {
|
|
626
|
+
guard errorMessage == nil, let host = firstHost else { return }
|
|
627
|
+
drawText("↗", at: NSPoint(x: 30, y: bounds.height - 28), font: .systemFont(ofSize: 16, weight: .bold), color: accent)
|
|
628
|
+
drawText("Open \(host)", at: NSPoint(x: 56, y: bounds.height - 26), font: .systemFont(ofSize: 14, weight: .semibold), color: accent)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private func drawEndpointLog() {
|
|
632
|
+
let titleY: CGFloat = 270
|
|
633
|
+
drawDivider(y: titleY - 12)
|
|
634
|
+
drawText("Endpoint log", at: NSPoint(x: 30, y: titleY), font: .systemFont(ofSize: 13, weight: .semibold), color: muted)
|
|
635
|
+
|
|
636
|
+
let entries = Array(endpointLog.prefix(2))
|
|
637
|
+
if entries.isEmpty {
|
|
638
|
+
drawText(
|
|
639
|
+
"Waiting for endpoint activity",
|
|
640
|
+
at: NSPoint(x: 30, y: titleY + 28),
|
|
641
|
+
font: .systemFont(ofSize: 13, weight: .medium),
|
|
642
|
+
color: muted
|
|
643
|
+
)
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
for (index, entry) in entries.enumerated() {
|
|
648
|
+
drawEndpointLogEntry(entry, y: titleY + 27 + CGFloat(index * 25))
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private func drawEmptyState() {
|
|
653
|
+
let card = NSRect(x: 30, y: 148, width: bounds.width - 60, height: 82)
|
|
654
|
+
drawGlassRow(card)
|
|
655
|
+
drawText("No hosts online", at: NSPoint(x: card.minX + 16, y: card.minY + 20), font: .systemFont(ofSize: 18, weight: .semibold), color: .white)
|
|
656
|
+
drawText("Configured setups will stay here when idle.", at: NSPoint(x: card.minX + 16, y: card.minY + 52), font: .systemFont(ofSize: 13, weight: .medium), color: muted)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private func drawRoute(_ route: LocalghostRoute, index: Int) {
|
|
660
|
+
let row = NSRect(x: 30, y: CGFloat(142 + index * 38), width: bounds.width - 60, height: 32)
|
|
661
|
+
drawGlassRow(row)
|
|
662
|
+
drawDot(at: NSPoint(x: row.minX + 16, y: row.midY), radius: 5, color: route.listening ? online : offline)
|
|
663
|
+
drawText(route.host, at: NSPoint(x: row.minX + 32, y: row.minY + 7), font: .systemFont(ofSize: 14, weight: .semibold), color: .white)
|
|
664
|
+
drawText(String(route.port), at: NSPoint(x: row.maxX - 54, y: row.minY + 7), font: .monospacedDigitSystemFont(ofSize: 14, weight: .medium), color: muted)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private func drawEndpointLogEntry(_ entry: LocalghostEndpointLogEntry, y: CGFloat) {
|
|
668
|
+
let row = NSRect(x: 30, y: y, width: bounds.width - 60, height: 22)
|
|
669
|
+
drawDot(at: NSPoint(x: row.minX + 8, y: row.midY), radius: 3, color: entry.active ? online : offline)
|
|
670
|
+
drawText(Self.logTimeFormatter.string(from: entry.timestamp), at: NSPoint(x: row.minX + 20, y: row.minY + 3), font: .monospacedDigitSystemFont(ofSize: 11, weight: .medium), color: muted)
|
|
671
|
+
drawText(entry.message, in: NSRect(x: row.minX + 78, y: row.minY + 2, width: row.width - 78, height: row.height), font: .systemFont(ofSize: 12, weight: .medium), color: .white)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private func drawGlassRow(_ rect: NSRect) {
|
|
675
|
+
let path = NSBezierPath(roundedRect: rect, xRadius: 8, yRadius: 8)
|
|
676
|
+
NSColor(calibratedWhite: 1.0, alpha: 0.08).setFill()
|
|
677
|
+
path.fill()
|
|
678
|
+
NSColor(calibratedWhite: 1.0, alpha: 0.10).setStroke()
|
|
679
|
+
path.lineWidth = 1
|
|
680
|
+
path.stroke()
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
private func drawStatusPill(text: String, at point: NSPoint) {
|
|
684
|
+
let textSize = text.size(withAttributes: [.font: NSFont.systemFont(ofSize: 13, weight: .semibold)])
|
|
685
|
+
let rect = NSRect(x: point.x, y: point.y, width: textSize.width + 34, height: 24)
|
|
686
|
+
let path = NSBezierPath(roundedRect: rect, xRadius: 12, yRadius: 12)
|
|
687
|
+
NSColor(calibratedWhite: 1.0, alpha: 0.08).setFill()
|
|
688
|
+
path.fill()
|
|
689
|
+
NSColor(calibratedWhite: 1.0, alpha: 0.11).setStroke()
|
|
690
|
+
path.lineWidth = 1
|
|
691
|
+
path.stroke()
|
|
692
|
+
drawDot(at: NSPoint(x: rect.minX + 12, y: rect.midY), radius: 4, color: statusColor)
|
|
693
|
+
drawText(text, at: NSPoint(x: rect.minX + 24, y: rect.minY + 4), font: .systemFont(ofSize: 13, weight: .semibold), color: muted)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private func statusText() -> String {
|
|
697
|
+
if errorMessage != nil { return "status unavailable" }
|
|
698
|
+
let count = onlineRoutes.count
|
|
699
|
+
return count == 1 ? "1 host online" : "\(count) hosts online"
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private var statusColor: NSColor {
|
|
703
|
+
errorMessage == nil ? online : offline
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private var accent: NSColor {
|
|
707
|
+
NSColor(calibratedRed: 0.68, green: 0.48, blue: 1.0, alpha: 1)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private var online: NSColor {
|
|
711
|
+
NSColor(calibratedRed: 0.36, green: 0.95, blue: 0.58, alpha: 1)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private var offline: NSColor {
|
|
715
|
+
NSColor(calibratedRed: 1.0, green: 0.42, blue: 0.50, alpha: 1)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
private var muted: NSColor {
|
|
719
|
+
NSColor(calibratedRed: 0.78, green: 0.78, blue: 0.92, alpha: 0.86)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
private func drawDivider(y: CGFloat) {
|
|
723
|
+
NSColor(calibratedWhite: 1.0, alpha: 0.12).setStroke()
|
|
724
|
+
let path = NSBezierPath()
|
|
725
|
+
path.move(to: NSPoint(x: 30, y: y))
|
|
726
|
+
path.line(to: NSPoint(x: bounds.width - 30, y: y))
|
|
727
|
+
path.lineWidth = 1
|
|
728
|
+
path.stroke()
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
private static let logTimeFormatter: DateFormatter = {
|
|
732
|
+
let formatter = DateFormatter()
|
|
733
|
+
formatter.dateFormat = "HH:mm:ss"
|
|
734
|
+
return formatter
|
|
735
|
+
}()
|
|
736
|
+
|
|
737
|
+
private func drawDot(at point: NSPoint, radius: CGFloat, color: NSColor) {
|
|
738
|
+
color.setFill()
|
|
739
|
+
NSBezierPath(ovalIn: NSRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2)).fill()
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private func drawText(_ text: String, at point: NSPoint, font: NSFont, color: NSColor) {
|
|
743
|
+
let attributes: [NSAttributedString.Key: Any] = [
|
|
744
|
+
.font: font,
|
|
745
|
+
.foregroundColor: color
|
|
746
|
+
]
|
|
747
|
+
text.draw(at: point, withAttributes: attributes)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
private func drawText(_ text: String, in rect: NSRect, font: NSFont, color: NSColor) {
|
|
751
|
+
let paragraph = NSMutableParagraphStyle()
|
|
752
|
+
paragraph.lineBreakMode = .byTruncatingTail
|
|
753
|
+
let attributes: [NSAttributedString.Key: Any] = [
|
|
754
|
+
.font: font,
|
|
755
|
+
.foregroundColor: color,
|
|
756
|
+
.paragraphStyle: paragraph
|
|
757
|
+
]
|
|
758
|
+
text.draw(in: rect, withAttributes: attributes)
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
enum LocalghostAssets {
|
|
763
|
+
static var templateLogo: NSImage? {
|
|
764
|
+
let image = processedLogo(color: .black)
|
|
765
|
+
image?.isTemplate = true
|
|
766
|
+
image?.size = NSSize(width: 18, height: 18)
|
|
767
|
+
return image
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
static var whiteLogo: NSImage? {
|
|
771
|
+
processedLogo(color: .white)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
private static func processedLogo(color: NSColor) -> NSImage? {
|
|
775
|
+
guard
|
|
776
|
+
let url = Bundle.main.url(forResource: "localghost-logo-source", withExtension: "png"),
|
|
777
|
+
let source = NSImage(contentsOf: url),
|
|
778
|
+
let cgImage = source.cgImage(forProposedRect: nil, context: nil, hints: nil)
|
|
779
|
+
else {
|
|
780
|
+
return nil
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
let width = cgImage.width
|
|
784
|
+
let height = cgImage.height
|
|
785
|
+
let bytesPerPixel = 4
|
|
786
|
+
let bytesPerRow = width * bytesPerPixel
|
|
787
|
+
var pixels = [UInt8](repeating: 0, count: height * bytesPerRow)
|
|
788
|
+
|
|
789
|
+
guard let context = CGContext(
|
|
790
|
+
data: &pixels,
|
|
791
|
+
width: width,
|
|
792
|
+
height: height,
|
|
793
|
+
bitsPerComponent: 8,
|
|
794
|
+
bytesPerRow: bytesPerRow,
|
|
795
|
+
space: CGColorSpaceCreateDeviceRGB(),
|
|
796
|
+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
797
|
+
) else {
|
|
798
|
+
return nil
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
802
|
+
|
|
803
|
+
let rgb = color.usingColorSpace(.deviceRGB) ?? color
|
|
804
|
+
let red = UInt8(max(0, min(255, rgb.redComponent * 255)))
|
|
805
|
+
let green = UInt8(max(0, min(255, rgb.greenComponent * 255)))
|
|
806
|
+
let blue = UInt8(max(0, min(255, rgb.blueComponent * 255)))
|
|
807
|
+
var minX = width
|
|
808
|
+
var minY = height
|
|
809
|
+
var maxX = 0
|
|
810
|
+
var maxY = 0
|
|
811
|
+
|
|
812
|
+
for offset in stride(from: 0, to: pixels.count, by: bytesPerPixel) {
|
|
813
|
+
let brightness = (Int(pixels[offset]) + Int(pixels[offset + 1]) + Int(pixels[offset + 2])) / 3
|
|
814
|
+
if brightness < 70 {
|
|
815
|
+
pixels[offset] = red
|
|
816
|
+
pixels[offset + 1] = green
|
|
817
|
+
pixels[offset + 2] = blue
|
|
818
|
+
pixels[offset + 3] = 255
|
|
819
|
+
let pixelIndex = offset / bytesPerPixel
|
|
820
|
+
let x = pixelIndex % width
|
|
821
|
+
let y = pixelIndex / width
|
|
822
|
+
minX = min(minX, x)
|
|
823
|
+
minY = min(minY, y)
|
|
824
|
+
maxX = max(maxX, x)
|
|
825
|
+
maxY = max(maxY, y)
|
|
826
|
+
} else {
|
|
827
|
+
pixels[offset + 3] = 0
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
guard let output = context.makeImage() else { return nil }
|
|
832
|
+
if minX > maxX || minY > maxY {
|
|
833
|
+
return NSImage(cgImage: output, size: NSSize(width: width, height: height))
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
let padding = max(8, Int(Double(max(maxX - minX, maxY - minY)) * 0.08))
|
|
837
|
+
let cropX = max(0, minX - padding)
|
|
838
|
+
let cropY = max(0, minY - padding)
|
|
839
|
+
let cropMaxX = min(width - 1, maxX + padding)
|
|
840
|
+
let cropMaxY = min(height - 1, maxY + padding)
|
|
841
|
+
let cropRect = CGRect(x: cropX, y: cropY, width: cropMaxX - cropX + 1, height: cropMaxY - cropY + 1)
|
|
842
|
+
|
|
843
|
+
guard let cropped = output.cropping(to: cropRect) else {
|
|
844
|
+
return NSImage(cgImage: output, size: NSSize(width: width, height: height))
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return NSImage(cgImage: cropped, size: NSSize(width: cropRect.width, height: cropRect.height))
|
|
195
848
|
}
|
|
196
849
|
}
|
|
197
850
|
|