@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.
@@ -1,19 +1,54 @@
1
1
  import AppKit
2
+ import Darwin
2
3
  import Foundation
3
4
 
4
- struct LocalghostPsResponse: Decodable {
5
- let activityPath: String?
6
- let runs: [LocalghostRun]
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 routes: [LocalghostRoute]
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.variableLength)
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
- statusItem.button?.title = "LG ..."
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?.title = "LG ?"
137
+ statusItem.button?.toolTip = "Localghost status unavailable"
64
138
  return
65
139
  }
66
140
 
67
- statusItem.button?.title = "LG \(latestRuns.count)"
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 apps running", action: nil, keyEquivalent: "")
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 title = latestRuns.count == 1 ? "1 Localghost app running" : "\(latestRuns.count) Localghost apps running"
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 title = "\(run.projectName) \(mode)"
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 pidItem = NSMenuItem(title: " pid \(run.pid)", action: nil, keyEquivalent: "")
123
- pidItem.isEnabled = false
124
- menu.addItem(pidItem)
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
- let output = try runLocalghostPs()
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 runLocalghostPs() throws -> Data {
153
- let task = Process()
154
- task.executableURL = URL(fileURLWithPath: "/usr/bin/env")
155
- task.arguments = localghostArguments()
156
- task.environment = [
157
- "PATH": pathValue()
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 stdout = Pipe()
161
- let stderr = Pipe()
162
- task.standardOutput = stdout
163
- task.standardError = stderr
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
- try task.run()
166
- task.waitUntilExit()
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
- let output = stdout.fileHandleForReading.readDataToEndOfFile()
169
- if task.terminationStatus == 0 {
170
- return output
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
- let errorData = stderr.fileHandleForReading.readDataToEndOfFile()
174
- let message = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
175
- throw LocalghostWidgetError.commandFailed(message?.isEmpty == false ? message! : "localghost ps failed")
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 localghostCommand() -> String {
179
- ProcessInfo.processInfo.environment["LOCALGHOST_CLI"] ?? "localghost"
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 localghostArguments() -> [String] {
183
- let command = localghostCommand()
184
- if command.hasSuffix(".js") {
185
- return ["node", command, "--no-update-check", "ps", "--json"]
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
- return [command, "--no-update-check", "ps", "--json"]
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
- private static func pathValue() -> String {
192
- let existing = ProcessInfo.processInfo.environment["PATH"] ?? ""
193
- let defaults = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
194
- return existing.isEmpty ? defaults : "\(existing):\(defaults)"
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