@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.
@@ -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
+ }