@hamedb89/localghost 0.1.6 → 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 +202 -22
- package/apps/macos-widget/LocalghostWidget.entitlements +12 -0
- package/apps/macos-widget/LocalghostWidget.swift +871 -0
- 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 +60 -0
- 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 +731 -91
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +138 -5
- package/dist/index.js +833 -37
- package/dist/index.js.map +1 -1
- package/dist/tunnel-DzfLXZ8O.d.ts +126 -0
- package/dist/vite.d.ts +5 -1
- package/dist/vite.js +717 -43
- 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 +28 -7
- package/docs/macos-widget.md +108 -0
- package/package.json +8 -2
- package/dist/config-Cde1Bich.d.ts +0 -31
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Darwin
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
struct LocalghostActivityFile: Decodable {
|
|
6
|
+
let runs: [LocalghostActivityRun]?
|
|
7
|
+
let setups: [LocalghostActivitySetup]?
|
|
8
|
+
}
|
|
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
|
|
24
|
+
let mode: String
|
|
25
|
+
let pid: Int
|
|
26
|
+
let cwd: String
|
|
27
|
+
let projectName: String
|
|
28
|
+
let startedAt: String
|
|
29
|
+
let updatedAt: String
|
|
30
|
+
let configPath: String?
|
|
31
|
+
let caddyfilePath: String?
|
|
32
|
+
let childCommand: [String]?
|
|
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?
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
struct LocalghostRoute: Decodable {
|
|
55
|
+
let host: String
|
|
56
|
+
let port: Int
|
|
57
|
+
let target: String
|
|
58
|
+
let listening: Bool
|
|
59
|
+
}
|
|
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
|
+
|
|
73
|
+
final class LocalghostWidgetApp: NSObject, NSApplicationDelegate {
|
|
74
|
+
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
|
75
|
+
private var timer: Timer?
|
|
76
|
+
private var latestRuns: [LocalghostRun] = []
|
|
77
|
+
private var latestError: String?
|
|
78
|
+
private var endpointLog: [LocalghostEndpointLogEntry] = []
|
|
79
|
+
private var desktopPanel: NSPanel?
|
|
80
|
+
private var desktopView: LocalghostDesktopWidgetView?
|
|
81
|
+
|
|
82
|
+
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
83
|
+
NSApp.setActivationPolicy(.accessory)
|
|
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()
|
|
93
|
+
rebuildMenu()
|
|
94
|
+
refresh()
|
|
95
|
+
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
|
|
96
|
+
self?.refresh()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private func refresh() {
|
|
101
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
102
|
+
let result = Self.loadRuns()
|
|
103
|
+
DispatchQueue.main.async {
|
|
104
|
+
switch result {
|
|
105
|
+
case .success(let runs):
|
|
106
|
+
self?.endpointLog = Self.updatedEndpointLog(
|
|
107
|
+
previousRuns: self?.latestRuns ?? [],
|
|
108
|
+
newRuns: runs,
|
|
109
|
+
currentLog: self?.endpointLog ?? []
|
|
110
|
+
)
|
|
111
|
+
self?.latestRuns = runs
|
|
112
|
+
self?.latestError = nil
|
|
113
|
+
Self.publishWidgetSnapshot(runs)
|
|
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
|
+
)
|
|
120
|
+
self?.latestRuns = []
|
|
121
|
+
self?.latestError = error.localizedDescription
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
self?.updateStatusTitle()
|
|
125
|
+
self?.desktopView?.update(
|
|
126
|
+
runs: self?.latestRuns ?? [],
|
|
127
|
+
errorMessage: self?.latestError,
|
|
128
|
+
endpointLog: self?.endpointLog ?? []
|
|
129
|
+
)
|
|
130
|
+
self?.rebuildMenu()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private func updateStatusTitle() {
|
|
136
|
+
if latestError != nil {
|
|
137
|
+
statusItem.button?.toolTip = "Localghost status unavailable"
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let runningCount = latestRuns.filter { $0.isRunning }.count
|
|
142
|
+
statusItem.button?.toolTip = "Localghost: \(runningCount) running, \(latestRuns.count) setup"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private func rebuildMenu() {
|
|
146
|
+
let menu = NSMenu()
|
|
147
|
+
|
|
148
|
+
if let latestError {
|
|
149
|
+
let item = NSMenuItem(title: "Localghost unavailable", action: nil, keyEquivalent: "")
|
|
150
|
+
item.isEnabled = false
|
|
151
|
+
menu.addItem(item)
|
|
152
|
+
|
|
153
|
+
let detail = NSMenuItem(title: latestError, action: nil, keyEquivalent: "")
|
|
154
|
+
detail.isEnabled = false
|
|
155
|
+
menu.addItem(detail)
|
|
156
|
+
} else if latestRuns.isEmpty {
|
|
157
|
+
let item = NSMenuItem(title: "No Localghost setups found", action: nil, keyEquivalent: "")
|
|
158
|
+
item.isEnabled = false
|
|
159
|
+
menu.addItem(item)
|
|
160
|
+
} else {
|
|
161
|
+
let runningCount = latestRuns.filter { $0.isRunning }.count
|
|
162
|
+
let title = "\(runningCount) running, \(latestRuns.count) setup"
|
|
163
|
+
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
164
|
+
item.isEnabled = false
|
|
165
|
+
menu.addItem(item)
|
|
166
|
+
|
|
167
|
+
menu.addItem(.separator())
|
|
168
|
+
|
|
169
|
+
for run in latestRuns {
|
|
170
|
+
addRun(run, to: menu)
|
|
171
|
+
menu.addItem(.separator())
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
addEndpointLog(to: menu)
|
|
175
|
+
}
|
|
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
|
+
|
|
182
|
+
let refreshItem = NSMenuItem(title: "Refresh", action: #selector(refreshFromMenu), keyEquivalent: "r")
|
|
183
|
+
refreshItem.target = self
|
|
184
|
+
menu.addItem(refreshItem)
|
|
185
|
+
|
|
186
|
+
let quitItem = NSMenuItem(title: "Quit Localghost Widget", action: #selector(quit), keyEquivalent: "q")
|
|
187
|
+
quitItem.target = self
|
|
188
|
+
menu.addItem(quitItem)
|
|
189
|
+
|
|
190
|
+
statusItem.menu = menu
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private func addRun(_ run: LocalghostRun, to menu: NSMenu) {
|
|
194
|
+
let command = run.childCommand?.joined(separator: " ")
|
|
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)"
|
|
198
|
+
let projectItem = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
199
|
+
projectItem.isEnabled = false
|
|
200
|
+
menu.addItem(projectItem)
|
|
201
|
+
|
|
202
|
+
let cwdItem = NSMenuItem(title: " \(run.cwd)", action: nil, keyEquivalent: "")
|
|
203
|
+
cwdItem.isEnabled = false
|
|
204
|
+
menu.addItem(cwdItem)
|
|
205
|
+
|
|
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
|
+
}
|
|
211
|
+
|
|
212
|
+
for route in run.routes {
|
|
213
|
+
let state = route.listening ? "listening" : "not listening"
|
|
214
|
+
let routeItem = NSMenuItem(title: " \(route.host) -> \(route.target) (\(state))", action: nil, keyEquivalent: "")
|
|
215
|
+
routeItem.isEnabled = false
|
|
216
|
+
menu.addItem(routeItem)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
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
|
+
|
|
237
|
+
@objc private func refreshFromMenu() {
|
|
238
|
+
refresh()
|
|
239
|
+
}
|
|
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
|
+
|
|
300
|
+
@objc private func quit() {
|
|
301
|
+
NSApp.terminate(nil)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private static func loadRuns() -> Result<[LocalghostRun], Error> {
|
|
305
|
+
do {
|
|
306
|
+
return .success(try loadActivityInstances())
|
|
307
|
+
} catch {
|
|
308
|
+
return .failure(error)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
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
|
+
}
|
|
337
|
+
|
|
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
|
+
}
|
|
354
|
+
|
|
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
|
+
}
|
|
361
|
+
|
|
362
|
+
return instances.sorted {
|
|
363
|
+
if $0.isRunning != $1.isRunning { return $0.isRunning && !$1.isRunning }
|
|
364
|
+
return $0.projectName.localizedCaseInsensitiveCompare($1.projectName) == .orderedAscending
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
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
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
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
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
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
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
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
|
|
522
|
+
}
|
|
523
|
+
|
|
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))
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
enum LocalghostWidgetError: LocalizedError {
|
|
852
|
+
case commandFailed(String)
|
|
853
|
+
|
|
854
|
+
var errorDescription: String? {
|
|
855
|
+
switch self {
|
|
856
|
+
case .commandFailed(let message):
|
|
857
|
+
return message
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
@main
|
|
863
|
+
struct LocalghostWidgetMain {
|
|
864
|
+
private static let delegate = LocalghostWidgetApp()
|
|
865
|
+
|
|
866
|
+
static func main() {
|
|
867
|
+
let app = NSApplication.shared
|
|
868
|
+
app.delegate = delegate
|
|
869
|
+
app.run()
|
|
870
|
+
}
|
|
871
|
+
}
|