aidevops 3.21.11 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/aidevops.sh +3 -2
  4. package/bun.lock +573 -0
  5. package/package.json +12 -1
  6. package/packages/gui-api/package.json +9 -0
  7. package/packages/gui-api/src/app.ts +49 -0
  8. package/packages/gui-api/src/file-adapter.ts +207 -0
  9. package/packages/gui-api/src/server.ts +12 -0
  10. package/packages/gui-api/src/status-adapter-utils.ts +108 -0
  11. package/packages/gui-api/src/status-adapter.ts +484 -0
  12. package/packages/gui-api/src/status-local-repos.ts +239 -0
  13. package/packages/gui-desktop/package.json +6 -0
  14. package/packages/gui-desktop/scripts/install-macos-app.sh +1020 -0
  15. package/packages/gui-shared/package.json +9 -0
  16. package/packages/gui-shared/src/contracts.ts +341 -0
  17. package/packages/gui-shared/src/fixtures.ts +250 -0
  18. package/packages/gui-shared/src/index.ts +2 -0
  19. package/packages/gui-web/index.html +24 -0
  20. package/packages/gui-web/package.json +25 -0
  21. package/packages/gui-web/src/App.tsx +269 -0
  22. package/packages/gui-web/src/AppNavigation.tsx +379 -0
  23. package/packages/gui-web/src/AppWorkspace.tsx +114 -0
  24. package/packages/gui-web/src/FileExplorerSurface.tsx +153 -0
  25. package/packages/gui-web/src/InventorySurfaces.tsx +117 -0
  26. package/packages/gui-web/src/PathActions.tsx +17 -0
  27. package/packages/gui-web/src/StatusSurfaces.tsx +396 -0
  28. package/packages/gui-web/src/app-model.ts +546 -0
  29. package/packages/gui-web/src/dashboard.ts +79 -0
  30. package/packages/gui-web/src/main.tsx +24 -0
  31. package/packages/gui-web/src/status-client.ts +93 -0
  32. package/packages/gui-web/src/styles.css +1540 -0
  33. package/packages/gui-web/vite.config.ts +14 -0
  34. package/setup.sh +78 -3
@@ -0,0 +1,1020 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-License-Identifier: MIT
3
+ # SPDX-FileCopyrightText: 2025-2026 Marcus Quinn
4
+
5
+ set -euo pipefail
6
+
7
+ APP_NAME="aidevops.app"
8
+ DEFAULT_APP_DIR="/Applications"
9
+
10
+ usage() {
11
+ printf 'Usage: %s [--check] [--app-dir DIR]\n' "$0"
12
+ return 0
13
+ }
14
+
15
+ repo_root() {
16
+ git rev-parse --show-toplevel
17
+ return 0
18
+ }
19
+
20
+ validate_environment() {
21
+ local root="$1"
22
+
23
+ if [[ "$(uname -s)" != "Darwin" ]]; then
24
+ printf 'gui desktop install currently supports macOS only\n' >&2
25
+ return 1
26
+ fi
27
+ if [[ ! -f "${root}/package.json" ]]; then
28
+ printf 'repo root missing package.json: %s\n' "$root" >&2
29
+ return 1
30
+ fi
31
+ if [[ ! -f "${root}/VERSION" ]]; then
32
+ printf 'repo root missing VERSION: %s\n' "$root" >&2
33
+ return 1
34
+ fi
35
+ if ! command -v bun >/dev/null 2>&1; then
36
+ printf 'bun is required to launch the current GUI scaffold\n' >&2
37
+ return 1
38
+ fi
39
+ if ! command -v sips >/dev/null 2>&1 || ! command -v iconutil >/dev/null 2>&1; then
40
+ printf 'macOS sips and iconutil are required to build the app icon\n' >&2
41
+ return 1
42
+ fi
43
+ if ! command -v swiftc >/dev/null 2>&1; then
44
+ printf 'macOS swiftc is required to build the native WebKit app wrapper\n' >&2
45
+ return 1
46
+ fi
47
+
48
+ return 0
49
+ }
50
+
51
+ gui_dependencies_present() {
52
+ local root="$1"
53
+
54
+ [[ -f "${root}/node_modules/vite/bin/vite.js" && -d "${root}/node_modules/react" && -d "${root}/node_modules/react-icons" ]]
55
+ return $?
56
+ }
57
+
58
+ ensure_gui_dependencies() {
59
+ local root="$1"
60
+
61
+ if gui_dependencies_present "$root"; then
62
+ return 0
63
+ fi
64
+
65
+ if [[ ! -f "${root}/bun.lock" ]]; then
66
+ printf 'GUI dependencies are missing and bun.lock was not found in %s\n' "$root" >&2
67
+ return 1
68
+ fi
69
+
70
+ printf 'Installing GUI dependencies with bun install --frozen-lockfile...\n'
71
+ if ! (cd "$root" && bun install --frozen-lockfile); then
72
+ printf 'GUI dependency installation failed in %s\n' "$root" >&2
73
+ return 1
74
+ fi
75
+
76
+ if ! gui_dependencies_present "$root"; then
77
+ printf 'GUI dependencies are still missing after bun install in %s\n' "$root" >&2
78
+ return 1
79
+ fi
80
+
81
+ return 0
82
+ }
83
+
84
+ app_version() {
85
+ local root="$1"
86
+
87
+ tr -d '\n' < "${root}/VERSION"
88
+ return 0
89
+ }
90
+
91
+ write_icon_assets() {
92
+ local resources_dir="$1"
93
+ local svg_path="${resources_dir}/aidevops.svg"
94
+ local png_path="${resources_dir}/aidevops-source.png"
95
+ local iconset_dir="${resources_dir}/aidevops.iconset"
96
+
97
+ cat > "$svg_path" <<'SVG'
98
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
99
+ <rect width="512" height="512" rx="64" fill="#0a0a0a"/>
100
+ <g transform="translate(36 36) scale(0.86)">
101
+ <path fill="#B2E969" d="M73.4 150.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160c12.5 12.5 12.5 32.8 0 45.3l-160 160c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L210.7 288 73.4 150.6zM240 400h192c17.7 0 32 14.3 32 32s-14.3 32-32 32H240c-17.7 0-32-14.3-32-32s14.3-32 32-32z"/>
102
+ </g>
103
+ </svg>
104
+ SVG
105
+
106
+ rm -rf "$iconset_dir"
107
+ mkdir -p "$iconset_dir"
108
+ sips -s format png "$svg_path" --out "$png_path" >/dev/null
109
+ sips -z 16 16 "$png_path" --out "${iconset_dir}/icon_16x16.png" >/dev/null
110
+ sips -z 32 32 "$png_path" --out "${iconset_dir}/icon_16x16@2x.png" >/dev/null
111
+ sips -z 32 32 "$png_path" --out "${iconset_dir}/icon_32x32.png" >/dev/null
112
+ sips -z 64 64 "$png_path" --out "${iconset_dir}/icon_32x32@2x.png" >/dev/null
113
+ sips -z 128 128 "$png_path" --out "${iconset_dir}/icon_128x128.png" >/dev/null
114
+ sips -z 256 256 "$png_path" --out "${iconset_dir}/icon_128x128@2x.png" >/dev/null
115
+ sips -z 256 256 "$png_path" --out "${iconset_dir}/icon_256x256.png" >/dev/null
116
+ sips -z 512 512 "$png_path" --out "${iconset_dir}/icon_256x256@2x.png" >/dev/null
117
+ sips -z 512 512 "$png_path" --out "${iconset_dir}/icon_512x512.png" >/dev/null
118
+ sips -z 1024 1024 "$png_path" --out "${iconset_dir}/icon_512x512@2x.png" >/dev/null
119
+ iconutil -c icns "$iconset_dir" -o "${resources_dir}/aidevops.icns"
120
+ rm -rf "$iconset_dir"
121
+ rm -f "$png_path"
122
+ return 0
123
+ }
124
+
125
+ write_service_helper() {
126
+ local root="$1"
127
+ local resources_dir="$2"
128
+ local helper_path="${resources_dir}/aidevops-gui-services.sh"
129
+
130
+ cat > "$helper_path" <<LAUNCHER
131
+ #!/usr/bin/env bash
132
+ set -euo pipefail
133
+
134
+ REPO_ROOT="${root}"
135
+ API_PORT="\${AIDEVOPS_GUI_API_PORT:-8787}"
136
+ WEB_PORT="\${AIDEVOPS_GUI_WEB_PORT:-5173}"
137
+ LOG_DIR="\${HOME}/Library/Logs/aidevops-gui"
138
+ LAUNCHER_LOG="\${LOG_DIR}/launcher.log"
139
+ BUN_BIN=""
140
+ MODE="\${1:-start}"
141
+ PATH="\${HOME}/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:\${PATH:-}"
142
+ export PATH
143
+ mkdir -p "\${LOG_DIR}"
144
+
145
+ cd "\${REPO_ROOT}"
146
+
147
+ notify() {
148
+ local message="\$1"
149
+ osascript -e "display notification \"\${message}\" with title \"aidevops\"" >/dev/null || true
150
+ return 0
151
+ }
152
+
153
+ url_ready() {
154
+ local url="\$1"
155
+ curl --silent --fail --max-time 2 "\${url}" >/dev/null 2>&1
156
+ return \$?
157
+ }
158
+
159
+ wait_for_url() {
160
+ local url="\$1"
161
+ local attempts="\$2"
162
+ local delay="\$3"
163
+ local attempt=1
164
+
165
+ while [[ "\${attempt}" -le "\${attempts}" ]]; do
166
+ if url_ready "\${url}"; then
167
+ return 0
168
+ fi
169
+ sleep "\${delay}"
170
+ attempt=\$((attempt + 1))
171
+ done
172
+
173
+ return 1
174
+ }
175
+
176
+ require_bun() {
177
+ local found=""
178
+
179
+ found="\$(command -v bun || true)"
180
+ if [[ -z "\${found}" ]]; then
181
+ printf 'bun is required to launch aidevops GUI; PATH=%s\n' "\${PATH}" >"\${LAUNCHER_LOG}"
182
+ notify "aidevops GUI cannot find bun. Install bun or add it to ~/.bun/bin."
183
+ return 1
184
+ fi
185
+ BUN_BIN="\${found}"
186
+ return 0
187
+ }
188
+
189
+ port_pids() {
190
+ local port="\$1"
191
+
192
+ lsof -t -nP -iTCP:"\${port}" -sTCP:LISTEN 2>/dev/null || true
193
+ return 0
194
+ }
195
+
196
+ pid_cwd() {
197
+ local pid="\$1"
198
+ local line=""
199
+
200
+ while IFS= read -r line; do
201
+ case "\${line}" in
202
+ n*)
203
+ printf '%s\n' "\${line#n}"
204
+ return 0
205
+ ;;
206
+ esac
207
+ done < <(lsof -a -p "\${pid}" -d cwd -Fn 2>/dev/null)
208
+
209
+ return 1
210
+ }
211
+
212
+ is_aidevops_checkout() {
213
+ local candidate="\$1"
214
+
215
+ [[ -f "\${candidate}/aidevops.sh" && -f "\${candidate}/packages/gui-web/vite.config.ts" ]]
216
+ return \$?
217
+ }
218
+
219
+ replace_stale_gui_on_port() {
220
+ local port="\$1"
221
+ local health_url="\$2"
222
+ local pid=""
223
+ local cwd=""
224
+
225
+ if ! url_ready "\${health_url}"; then
226
+ return 0
227
+ fi
228
+
229
+ while IFS= read -r pid; do
230
+ if [[ -z "\${pid}" ]]; then
231
+ continue
232
+ fi
233
+ cwd="\$(pid_cwd "\${pid}" || true)"
234
+ if [[ "\${cwd}" == "\${REPO_ROOT}" ]]; then
235
+ return 0
236
+ fi
237
+ done < <(port_pids "\${port}")
238
+
239
+ while IFS= read -r pid; do
240
+ if [[ -z "\${pid}" ]]; then
241
+ continue
242
+ fi
243
+ cwd="\$(pid_cwd "\${pid}" || true)"
244
+ if [[ -n "\${cwd}" ]] && is_aidevops_checkout "\${cwd}"; then
245
+ kill "\${pid}" >/dev/null 2>&1 || true
246
+ fi
247
+ done < <(port_pids "\${port}")
248
+
249
+ return 0
250
+ }
251
+
252
+ kill_gui_on_port_for_repo() {
253
+ local port="\$1"
254
+ local pid=""
255
+ local cwd=""
256
+
257
+ while IFS= read -r pid; do
258
+ if [[ -z "\${pid}" ]]; then
259
+ continue
260
+ fi
261
+ cwd="\$(pid_cwd "\${pid}" || true)"
262
+ if [[ "\${cwd}" == "\${REPO_ROOT}" ]]; then
263
+ kill "\${pid}" >/dev/null 2>&1 || true
264
+ fi
265
+ done < <(port_pids "\${port}")
266
+
267
+ return 0
268
+ }
269
+
270
+ stop_gui_services() {
271
+ kill_gui_on_port_for_repo "\${API_PORT}"
272
+ kill_gui_on_port_for_repo "\${WEB_PORT}"
273
+ return 0
274
+ }
275
+
276
+ api_contract_current() {
277
+ local payload=""
278
+
279
+ payload="\$(curl --silent --fail --max-time 2 "http://127.0.0.1:\${API_PORT}/api/status" 2>/dev/null || true)"
280
+ if [[ "\${payload}" == *'"local_repos"'* && "\${payload}" == *'"oauth_pool"'* ]]; then
281
+ return 0
282
+ fi
283
+ return 1
284
+ }
285
+
286
+ if [[ "\${MODE}" == "stop" ]]; then
287
+ stop_gui_services
288
+ exit 0
289
+ fi
290
+
291
+ if [[ -f "\${HOME}/.aidevops/agents/VERSION" && -f "\${REPO_ROOT}/VERSION" ]]; then
292
+ INSTALLED_VERSION="\$(tr -d '\n' < "\${HOME}/.aidevops/agents/VERSION")"
293
+ RUNNING_VERSION="\$(tr -d '\n' < "\${REPO_ROOT}/VERSION")"
294
+ if [[ "\${INSTALLED_VERSION}" != "\${RUNNING_VERSION}" ]]; then
295
+ notify "Restart aidevops GUI after update: installed \${INSTALLED_VERSION}, app \${RUNNING_VERSION}."
296
+ fi
297
+ fi
298
+
299
+ replace_stale_gui_on_port "\${API_PORT}" "http://127.0.0.1:\${API_PORT}/api/status"
300
+ replace_stale_gui_on_port "\${WEB_PORT}" "http://127.0.0.1:\${WEB_PORT}/"
301
+ if url_ready "http://127.0.0.1:\${API_PORT}/api/status" && ! api_contract_current; then
302
+ kill_gui_on_port_for_repo "\${API_PORT}"
303
+ kill_gui_on_port_for_repo "\${WEB_PORT}"
304
+ fi
305
+ sleep 1
306
+
307
+ if ! url_ready "http://127.0.0.1:\${API_PORT}/api/status" || ! url_ready "http://127.0.0.1:\${WEB_PORT}/"; then
308
+ require_bun
309
+ fi
310
+
311
+ if ! url_ready "http://127.0.0.1:\${API_PORT}/api/status"; then
312
+ nohup env AIDEVOPS_GUI_API_PORT="\${API_PORT}" "\${BUN_BIN}" run packages/gui-api/src/server.ts >"\${LOG_DIR}/api.log" 2>&1 &
313
+ fi
314
+ if ! url_ready "http://127.0.0.1:\${WEB_PORT}/"; then
315
+ nohup "\${BUN_BIN}" ./node_modules/vite/bin/vite.js --config packages/gui-web/vite.config.ts --host 127.0.0.1 --port "\${WEB_PORT}" >"\${LOG_DIR}/web.log" 2>&1 &
316
+ fi
317
+
318
+ if ! wait_for_url "http://127.0.0.1:\${API_PORT}/api/status" 20 1; then
319
+ printf 'aidevops GUI API did not become ready. Check %s/api.log.\n' "\${LOG_DIR}" >"\${LAUNCHER_LOG}"
320
+ notify "aidevops GUI API did not become ready. Check \${LOG_DIR}/api.log."
321
+ exit 1
322
+ fi
323
+ if ! wait_for_url "http://127.0.0.1:\${WEB_PORT}/" 30 1; then
324
+ printf 'aidevops GUI web did not become ready. Check %s/web.log.\n' "\${LOG_DIR}" >"\${LAUNCHER_LOG}"
325
+ notify "aidevops GUI web did not become ready. Check \${LOG_DIR}/web.log."
326
+ exit 1
327
+ fi
328
+
329
+ exit 0
330
+ LAUNCHER
331
+ chmod 755 "$helper_path"
332
+ return 0
333
+ }
334
+
335
+ write_webview_source() {
336
+ local resources_dir="$1"
337
+ local swift_source="${resources_dir}/aidevops-gui.swift"
338
+
339
+ cat > "$swift_source" <<'SWIFT'
340
+ import AppKit
341
+ import WebKit
342
+
343
+ final class DraggableTitlebarView: NSView {
344
+ override var mouseDownCanMoveWindow: Bool {
345
+ return true
346
+ }
347
+
348
+ override func mouseDown(with event: NSEvent) {
349
+ window?.performDrag(with: event)
350
+ }
351
+ }
352
+
353
+ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScriptMessageHandler, NSMenuItemValidation, NSWindowDelegate {
354
+ private var window: NSWindow!
355
+ private var aboutWindow: NSWindow?
356
+ private var titlebarToggleButton: NSButton?
357
+ private var webView: WKWebView!
358
+ private let apiPort = ProcessInfo.processInfo.environment["AIDEVOPS_GUI_API_PORT"] ?? "8787"
359
+ private let webPort = ProcessInfo.processInfo.environment["AIDEVOPS_GUI_WEB_PORT"] ?? "5173"
360
+ private let defaultAccentHue: CGFloat = 123
361
+ private let mainWindowFrameAutosaveName = "aidevops-main-window"
362
+ private let titlebarHeight: CGFloat = 24
363
+
364
+ func applicationDidFinishLaunching(_ notification: Notification) {
365
+ configureMenu()
366
+ configureWindow()
367
+ loadStatusHTML(title: "Starting aidevops", detail: "Preparing the local read-only GUI services…")
368
+ startServicesAndLoadApp()
369
+ }
370
+
371
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
372
+ return false
373
+ }
374
+
375
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
376
+ saveMainWindowFrame()
377
+ stopServices()
378
+ return .terminateNow
379
+ }
380
+
381
+ func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
382
+ if !flag {
383
+ openDashboard(nil)
384
+ }
385
+ return true
386
+ }
387
+
388
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
389
+ guard message.name == "accentHue" else {
390
+ return
391
+ }
392
+
393
+ if let number = message.body as? NSNumber {
394
+ updateTitlebarAccent(hue: CGFloat(number.doubleValue))
395
+ return
396
+ }
397
+
398
+ if let value = message.body as? String, let hue = Double(value) {
399
+ updateTitlebarAccent(hue: CGFloat(hue))
400
+ }
401
+ }
402
+
403
+ private func configureMenu() {
404
+ let appName = "aidevops"
405
+ let mainMenu = NSMenu(title: "Main Menu")
406
+
407
+ let appMenuItem = NSMenuItem()
408
+ let appMenu = NSMenu(title: appName)
409
+ appMenu.addItem(menuItem("About \(appName)", action: #selector(showAbout(_:)), target: self))
410
+ appMenu.addItem(.separator())
411
+
412
+ let servicesItem = NSMenuItem(title: "Services", action: nil, keyEquivalent: "")
413
+ let servicesMenu = NSMenu(title: "Services")
414
+ servicesItem.submenu = servicesMenu
415
+ appMenu.addItem(servicesItem)
416
+ NSApp.servicesMenu = servicesMenu
417
+
418
+ appMenu.addItem(.separator())
419
+ appMenu.addItem(menuItem("Hide \(appName)", action: #selector(NSApplication.hide(_:)), key: "h", target: NSApp))
420
+ appMenu.addItem(menuItem("Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), key: "h", modifiers: [.command, .option], target: NSApp))
421
+ appMenu.addItem(menuItem("Show All", action: #selector(NSApplication.unhideAllApplications(_:)), target: NSApp))
422
+ appMenu.addItem(.separator())
423
+ appMenu.addItem(menuItem("Quit \(appName)", action: #selector(quitApplication(_:)), key: "q", target: self))
424
+ appMenuItem.submenu = appMenu
425
+ mainMenu.addItem(appMenuItem)
426
+
427
+ let fileMenuItem = NSMenuItem()
428
+ let fileMenu = NSMenu(title: "File")
429
+ fileMenu.addItem(menuItem("Open Dashboard", action: #selector(openDashboard(_:)), key: "n", target: self))
430
+ fileMenu.addItem(.separator())
431
+ fileMenu.addItem(menuItem("Close Window", action: #selector(NSWindow.performClose(_:)), key: "w"))
432
+ fileMenuItem.submenu = fileMenu
433
+ mainMenu.addItem(fileMenuItem)
434
+
435
+ let editMenuItem = NSMenuItem()
436
+ let editMenu = NSMenu(title: "Edit")
437
+ editMenu.addItem(menuItem("Undo", action: Selector(("undo:")), key: "z"))
438
+ editMenu.addItem(menuItem("Redo", action: Selector(("redo:")), key: "Z", modifiers: [.command, .shift]))
439
+ editMenu.addItem(.separator())
440
+ editMenu.addItem(menuItem("Cut", action: #selector(NSText.cut(_:)), key: "x"))
441
+ editMenu.addItem(menuItem("Copy", action: #selector(NSText.copy(_:)), key: "c"))
442
+ editMenu.addItem(menuItem("Paste", action: #selector(NSText.paste(_:)), key: "v"))
443
+ editMenu.addItem(menuItem("Paste and Match Style", action: #selector(NSTextView.pasteAsPlainText(_:)), key: "v", modifiers: [.command, .option, .shift]))
444
+ editMenu.addItem(menuItem("Delete", action: #selector(NSText.delete(_:))))
445
+ editMenu.addItem(.separator())
446
+ editMenu.addItem(menuItem("Select All", action: #selector(NSText.selectAll(_:)), key: "a"))
447
+ editMenuItem.submenu = editMenu
448
+ mainMenu.addItem(editMenuItem)
449
+
450
+ let viewMenuItem = NSMenuItem()
451
+ let viewMenu = NSMenu(title: "View")
452
+ viewMenu.addItem(menuItem("Reload Page", action: #selector(reloadPage(_:)), key: "r", target: self))
453
+ viewMenu.addItem(menuItem("Stop Loading", action: #selector(stopLoading(_:)), key: ".", target: self))
454
+ viewMenu.addItem(.separator())
455
+ viewMenu.addItem(menuItem("Back", action: #selector(goBack(_:)), key: "[", target: self))
456
+ viewMenu.addItem(menuItem("Forward", action: #selector(goForward(_:)), key: "]", target: self))
457
+ viewMenu.addItem(.separator())
458
+ viewMenu.addItem(menuItem("Actual Size", action: #selector(resetZoom(_:)), key: "0", target: self))
459
+ viewMenu.addItem(menuItem("Zoom In", action: #selector(zoomIn(_:)), key: "+", target: self))
460
+ viewMenu.addItem(menuItem("Zoom Out", action: #selector(zoomOut(_:)), key: "-", target: self))
461
+ viewMenu.addItem(.separator())
462
+ viewMenu.addItem(menuItem("Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)), key: "f", modifiers: [.command, .control]))
463
+ viewMenuItem.submenu = viewMenu
464
+ mainMenu.addItem(viewMenuItem)
465
+
466
+ let windowMenuItem = NSMenuItem()
467
+ let windowMenu = NSMenu(title: "Window")
468
+ windowMenu.addItem(menuItem("Minimize", action: #selector(NSWindow.miniaturize(_:)), key: "m"))
469
+ windowMenu.addItem(menuItem("Zoom", action: #selector(NSWindow.performZoom(_:))))
470
+ windowMenu.addItem(menuItem("Hide Window", action: #selector(hideWindow(_:)), key: "h", modifiers: [.command, .shift], target: self))
471
+ windowMenu.addItem(.separator())
472
+ windowMenu.addItem(menuItem("Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), target: NSApp))
473
+ windowMenuItem.submenu = windowMenu
474
+ mainMenu.addItem(windowMenuItem)
475
+ NSApp.windowsMenu = windowMenu
476
+
477
+ let helpMenuItem = NSMenuItem()
478
+ let helpMenu = NSMenu(title: "Help")
479
+ helpMenu.addItem(menuItem("aidevops Help", action: #selector(showHelp(_:)), key: "?", modifiers: [.command, .shift], target: self))
480
+ helpMenuItem.submenu = helpMenu
481
+ mainMenu.addItem(helpMenuItem)
482
+ NSApp.helpMenu = helpMenu
483
+
484
+ NSApp.mainMenu = mainMenu
485
+ }
486
+
487
+ private func menuItem(_ title: String, action: Selector?, key: String = "", modifiers: NSEvent.ModifierFlags = .command, target: AnyObject? = nil) -> NSMenuItem {
488
+ let item = NSMenuItem(title: title, action: action, keyEquivalent: key)
489
+ item.keyEquivalentModifierMask = key.isEmpty ? [] : modifiers
490
+ item.target = target
491
+ return item
492
+ }
493
+
494
+ func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
495
+ switch menuItem.action {
496
+ case #selector(goBack(_:)):
497
+ return webView?.canGoBack ?? false
498
+ case #selector(goForward(_:)):
499
+ return webView?.canGoForward ?? false
500
+ case #selector(reloadPage(_:)), #selector(stopLoading(_:)), #selector(resetZoom(_:)), #selector(zoomIn(_:)), #selector(zoomOut(_:)):
501
+ return webView != nil
502
+ default:
503
+ return true
504
+ }
505
+ }
506
+
507
+ @objc private func showAbout(_ sender: Any?) {
508
+ if let existingWindow = aboutWindow {
509
+ existingWindow.makeKeyAndOrderFront(nil)
510
+ NSApp.activate(ignoringOtherApps: true)
511
+ return
512
+ }
513
+
514
+ let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
515
+ let about = NSWindow(
516
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 430),
517
+ styleMask: [.titled, .closable],
518
+ backing: .buffered,
519
+ defer: false
520
+ )
521
+ about.title = "About aidevops"
522
+ about.isReleasedWhenClosed = false
523
+
524
+ let content = NSView()
525
+ let stack = NSStackView()
526
+ stack.orientation = .vertical
527
+ stack.alignment = .leading
528
+ stack.spacing = 12
529
+ stack.edgeInsets = NSEdgeInsets(top: 28, left: 28, bottom: 28, right: 28)
530
+ stack.translatesAutoresizingMaskIntoConstraints = false
531
+
532
+ let title = aboutLabel("aidevops", font: .systemFont(ofSize: 28, weight: .bold), color: .labelColor)
533
+ let versionLabel = aboutLabel("Version \(version)", font: .systemFont(ofSize: 13, weight: .semibold), color: .secondaryLabelColor)
534
+ let detail = aboutLabel("Native macOS WebKit wrapper for the local read-only aidevops GUI. It provides AI-assisted development workflows, code quality, and deployment automation without exposing secrets or shell write routes.", font: .systemFont(ofSize: 13), color: .labelColor)
535
+ let copyright = aboutLabel("Copyright © 2025-2026 Marcus Quinn. Licensed under the MIT License.", font: .systemFont(ofSize: 12), color: .secondaryLabelColor)
536
+
537
+ let buttonRow = NSStackView()
538
+ buttonRow.orientation = .horizontal
539
+ buttonRow.spacing = 10
540
+ buttonRow.alignment = .centerY
541
+ buttonRow.addArrangedSubview(aboutButton("Website", action: #selector(openWebsite(_:))))
542
+ buttonRow.addArrangedSubview(aboutButton("GitHub Repository", action: #selector(openGitHub(_:))))
543
+
544
+ stack.addArrangedSubview(title)
545
+ stack.addArrangedSubview(versionLabel)
546
+ stack.addArrangedSubview(detail)
547
+ stack.addArrangedSubview(buttonRow)
548
+ stack.addArrangedSubview(copyright)
549
+ content.addSubview(stack)
550
+ NSLayoutConstraint.activate([
551
+ stack.leadingAnchor.constraint(equalTo: content.leadingAnchor),
552
+ stack.trailingAnchor.constraint(equalTo: content.trailingAnchor),
553
+ stack.topAnchor.constraint(equalTo: content.topAnchor),
554
+ stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor)
555
+ ])
556
+
557
+ about.contentView = content
558
+ about.center()
559
+ aboutWindow = about
560
+ about.makeKeyAndOrderFront(nil)
561
+ NSApp.activate(ignoringOtherApps: true)
562
+ }
563
+
564
+ private func aboutLabel(_ value: String, font: NSFont, color: NSColor) -> NSTextField {
565
+ let field = NSTextField(labelWithString: value)
566
+ field.font = font
567
+ field.textColor = color
568
+ field.lineBreakMode = .byWordWrapping
569
+ field.maximumNumberOfLines = 0
570
+ field.translatesAutoresizingMaskIntoConstraints = false
571
+ field.widthAnchor.constraint(lessThanOrEqualToConstant: 462).isActive = true
572
+ return field
573
+ }
574
+
575
+ private func aboutButton(_ title: String, action: Selector) -> NSButton {
576
+ let button = NSButton(title: title, target: self, action: action)
577
+ button.bezelStyle = .rounded
578
+ return button
579
+ }
580
+
581
+ @objc private func openWebsite(_ sender: Any?) {
582
+ openExternalURL("https://aidevops.sh")
583
+ }
584
+
585
+ @objc private func openGitHub(_ sender: Any?) {
586
+ openExternalURL("https://github.com/marcusquinn/aidevops")
587
+ }
588
+
589
+ private func openExternalURL(_ value: String) {
590
+ guard let url = URL(string: value) else {
591
+ return
592
+ }
593
+ NSWorkspace.shared.open(url)
594
+ }
595
+
596
+ @objc private func openDashboard(_ sender: Any?) {
597
+ window.makeKeyAndOrderFront(nil)
598
+ NSApp.activate(ignoringOtherApps: true)
599
+ loadAppURL()
600
+ }
601
+
602
+ @objc private func hideWindow(_ sender: Any?) {
603
+ saveMainWindowFrame()
604
+ window.orderOut(nil)
605
+ }
606
+
607
+ @objc private func quitApplication(_ sender: Any?) {
608
+ saveMainWindowFrame()
609
+ NSApp.terminate(nil)
610
+ }
611
+
612
+ func windowWillClose(_ notification: Notification) {
613
+ saveMainWindowFrame()
614
+ }
615
+
616
+ @objc private func reloadPage(_ sender: Any?) {
617
+ webView.reload()
618
+ }
619
+
620
+ @objc private func stopLoading(_ sender: Any?) {
621
+ webView.stopLoading()
622
+ }
623
+
624
+ @objc private func goBack(_ sender: Any?) {
625
+ if webView.canGoBack {
626
+ webView.goBack()
627
+ }
628
+ }
629
+
630
+ @objc private func goForward(_ sender: Any?) {
631
+ if webView.canGoForward {
632
+ webView.goForward()
633
+ }
634
+ }
635
+
636
+ @objc private func resetZoom(_ sender: Any?) {
637
+ webView.pageZoom = 1.0
638
+ }
639
+
640
+ @objc private func zoomIn(_ sender: Any?) {
641
+ webView.pageZoom = min(webView.pageZoom + 0.1, 3.0)
642
+ }
643
+
644
+ @objc private func zoomOut(_ sender: Any?) {
645
+ webView.pageZoom = max(webView.pageZoom - 0.1, 0.5)
646
+ }
647
+
648
+ @objc private func showHelp(_ sender: Any?) {
649
+ window.makeKeyAndOrderFront(nil)
650
+ NSApp.activate(ignoringOtherApps: true)
651
+ loadStatusHTML(title: "aidevops Help", detail: "Use the app menus for standard editing, view controls, reload/navigation, window management, and help. The desktop wrapper runs the local read-only aidevops GUI in WebKit.")
652
+ }
653
+
654
+ private func configureWindow() {
655
+ let configuration = WKWebViewConfiguration()
656
+ configuration.defaultWebpagePreferences.allowsContentJavaScript = true
657
+ let userContentController = WKUserContentController()
658
+ userContentController.add(self, name: "accentHue")
659
+ configuration.userContentController = userContentController
660
+ webView = WKWebView(frame: .zero, configuration: configuration)
661
+ webView.navigationDelegate = self
662
+ webView.translatesAutoresizingMaskIntoConstraints = false
663
+
664
+ let contentContainer = NSView()
665
+ contentContainer.translatesAutoresizingMaskIntoConstraints = false
666
+ let titlebarOverlay = makeTitlebarOverlay()
667
+ contentContainer.addSubview(webView)
668
+ contentContainer.addSubview(titlebarOverlay)
669
+ NSLayoutConstraint.activate([
670
+ webView.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor),
671
+ webView.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor),
672
+ webView.topAnchor.constraint(equalTo: contentContainer.topAnchor),
673
+ webView.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor),
674
+ titlebarOverlay.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor),
675
+ titlebarOverlay.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor),
676
+ titlebarOverlay.topAnchor.constraint(equalTo: contentContainer.topAnchor),
677
+ titlebarOverlay.heightAnchor.constraint(equalToConstant: titlebarHeight)
678
+ ])
679
+
680
+ window = NSWindow(
681
+ contentRect: NSRect(x: 0, y: 0, width: 1280, height: 860),
682
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
683
+ backing: .buffered,
684
+ defer: false
685
+ )
686
+ window.center()
687
+ window.title = "aidevops"
688
+ window.titleVisibility = .hidden
689
+ window.titlebarAppearsTransparent = true
690
+ window.styleMask.insert(.fullSizeContentView)
691
+ window.isMovableByWindowBackground = true
692
+ window.isReleasedWhenClosed = false
693
+ window.delegate = self
694
+ window.setFrameAutosaveName(mainWindowFrameAutosaveName)
695
+ window.contentView = contentContainer
696
+ if !window.setFrameUsingName(mainWindowFrameAutosaveName) {
697
+ window.center()
698
+ }
699
+ window.makeKeyAndOrderFront(nil)
700
+ NSApp.activate(ignoringOtherApps: true)
701
+ }
702
+
703
+ private func makeTitlebarOverlay() -> NSView {
704
+ let overlay = DraggableTitlebarView()
705
+ overlay.translatesAutoresizingMaskIntoConstraints = false
706
+ let defaultButtonAccent = accentColor(hueDegrees: defaultAccentHue, alpha: 0.9)
707
+
708
+ let toggleButton = NSButton(frame: .zero)
709
+ toggleButton.action = #selector(toggleMachineRail(_:))
710
+ toggleButton.bezelStyle = .regularSquare
711
+ toggleButton.contentTintColor = defaultButtonAccent
712
+ toggleButton.image = sidebarToggleImage()
713
+ toggleButton.imagePosition = .imageOnly
714
+ toggleButton.isBordered = false
715
+ toggleButton.setButtonType(.momentaryChange)
716
+ toggleButton.target = self
717
+ toggleButton.toolTip = "Hide or show the machine sidebar"
718
+ toggleButton.translatesAutoresizingMaskIntoConstraints = false
719
+
720
+ overlay.addSubview(toggleButton)
721
+ titlebarToggleButton = toggleButton
722
+ NSLayoutConstraint.activate([
723
+ toggleButton.leadingAnchor.constraint(equalTo: overlay.leadingAnchor, constant: 92),
724
+ toggleButton.centerYAnchor.constraint(equalTo: overlay.centerYAnchor, constant: 3.5),
725
+ toggleButton.heightAnchor.constraint(equalToConstant: 22),
726
+ toggleButton.widthAnchor.constraint(equalToConstant: 22)
727
+ ])
728
+
729
+ return overlay
730
+ }
731
+
732
+ private func updateTitlebarAccent(hue: CGFloat) {
733
+ let normalizedHue = min(359, max(0, hue))
734
+ titlebarToggleButton?.contentTintColor = accentColor(hueDegrees: normalizedHue, alpha: 0.9)
735
+ }
736
+
737
+ private func accentColor(hueDegrees: CGFloat, alpha: CGFloat) -> NSColor {
738
+ let hue = hueDegrees.truncatingRemainder(dividingBy: 360) / 360
739
+ let saturation: CGFloat = 0.74
740
+ let lightness: CGFloat = 0.66
741
+ let chroma = (1 - abs((2 * lightness) - 1)) * saturation
742
+ let huePrime = hue * 6
743
+ let x = chroma * (1 - abs(huePrime.truncatingRemainder(dividingBy: 2) - 1))
744
+ let match = lightness - (chroma / 2)
745
+ let red: CGFloat
746
+ let green: CGFloat
747
+ let blue: CGFloat
748
+
749
+ if huePrime < 1 {
750
+ red = chroma
751
+ green = x
752
+ blue = 0
753
+ } else if huePrime < 2 {
754
+ red = x
755
+ green = chroma
756
+ blue = 0
757
+ } else if huePrime < 3 {
758
+ red = 0
759
+ green = chroma
760
+ blue = x
761
+ } else if huePrime < 4 {
762
+ red = 0
763
+ green = x
764
+ blue = chroma
765
+ } else if huePrime < 5 {
766
+ red = x
767
+ green = 0
768
+ blue = chroma
769
+ } else {
770
+ red = chroma
771
+ green = 0
772
+ blue = x
773
+ }
774
+
775
+ return NSColor(srgbRed: red + match, green: green + match, blue: blue + match, alpha: alpha)
776
+ }
777
+
778
+ private func sidebarToggleImage() -> NSImage {
779
+ let image = NSImage(size: NSSize(width: 20, height: 20), flipped: false) { _ in
780
+ let outer = NSBezierPath(roundedRect: NSRect(x: 3, y: 3, width: 14, height: 14), xRadius: 3, yRadius: 3)
781
+ outer.lineWidth = 2.2
782
+ NSColor.labelColor.setStroke()
783
+ outer.stroke()
784
+
785
+ let divider = NSBezierPath()
786
+ divider.lineWidth = 2
787
+ divider.move(to: NSPoint(x: 8, y: 4))
788
+ divider.line(to: NSPoint(x: 8, y: 16))
789
+ divider.stroke()
790
+
791
+ let notchTop = NSBezierPath()
792
+ notchTop.lineWidth = 1.7
793
+ notchTop.move(to: NSPoint(x: 4.8, y: 12.4))
794
+ notchTop.line(to: NSPoint(x: 6.1, y: 12.4))
795
+ notchTop.stroke()
796
+
797
+ let notchBottom = NSBezierPath()
798
+ notchBottom.lineWidth = 1.7
799
+ notchBottom.move(to: NSPoint(x: 4.8, y: 7.6))
800
+ notchBottom.line(to: NSPoint(x: 6.1, y: 7.6))
801
+ notchBottom.stroke()
802
+
803
+ return true
804
+ }
805
+ image.isTemplate = true
806
+ return image
807
+ }
808
+
809
+ @objc private func toggleMachineRail(_ sender: Any?) {
810
+ webView.evaluateJavaScript("window.dispatchEvent(new CustomEvent('aidevops:toggle-machine-rail')); true;", completionHandler: nil)
811
+ }
812
+
813
+ private func saveMainWindowFrame() {
814
+ guard window != nil else {
815
+ return
816
+ }
817
+ window.saveFrame(usingName: mainWindowFrameAutosaveName)
818
+ }
819
+
820
+ private func startServicesAndLoadApp() {
821
+ DispatchQueue.global(qos: .userInitiated).async {
822
+ let result = self.runServiceHelper()
823
+ DispatchQueue.main.async {
824
+ if result.ok {
825
+ self.loadAppURL()
826
+ } else {
827
+ self.loadStatusHTML(title: "aidevops could not start", detail: result.message)
828
+ }
829
+ }
830
+ }
831
+ }
832
+
833
+ private func runServiceHelper() -> (ok: Bool, message: String) {
834
+ return runServiceHelper(arguments: [])
835
+ }
836
+
837
+ private func stopServices() {
838
+ _ = runServiceHelper(arguments: ["stop"])
839
+ }
840
+
841
+ private func runServiceHelper(arguments: [String]) -> (ok: Bool, message: String) {
842
+ guard let helperPath = Bundle.main.path(forResource: "aidevops-gui-services", ofType: "sh") else {
843
+ return (false, "The bundled service helper is missing from aidevops.app.")
844
+ }
845
+
846
+ let process = Process()
847
+ let pipe = Pipe()
848
+ process.executableURL = URL(fileURLWithPath: "/bin/bash")
849
+ process.arguments = [helperPath] + arguments
850
+ var environment = ProcessInfo.processInfo.environment
851
+ environment["AIDEVOPS_GUI_WRAPPER"] = "webkit"
852
+ process.environment = environment
853
+ process.standardOutput = pipe
854
+ process.standardError = pipe
855
+
856
+ do {
857
+ try process.run()
858
+ process.waitUntilExit()
859
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
860
+ let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
861
+ if process.terminationStatus == 0 {
862
+ return (true, output)
863
+ }
864
+ if output.isEmpty {
865
+ return (false, "The local GUI services exited with status \(process.terminationStatus). Check ~/Library/Logs/aidevops-gui/.")
866
+ }
867
+ return (false, output)
868
+ } catch {
869
+ return (false, "Could not launch the local GUI service helper: \(error.localizedDescription)")
870
+ }
871
+ }
872
+
873
+ private func loadAppURL() {
874
+ guard let url = URL(string: "http://127.0.0.1:\(webPort)/?desktop=macos") else {
875
+ loadStatusHTML(title: "Invalid aidevops URL", detail: "The configured web port is not valid: \(webPort)")
876
+ return
877
+ }
878
+ webView.load(URLRequest(url: url))
879
+ }
880
+
881
+ private func loadStatusHTML(title: String, detail: String) {
882
+ let escapedTitle = escapeHTML(title)
883
+ let escapedDetail = escapeHTML(detail).replacingOccurrences(of: "\n", with: "<br>")
884
+ let html = """
885
+ <!doctype html>
886
+ <html lang="en">
887
+ <head>
888
+ <meta charset="utf-8">
889
+ <meta name="viewport" content="width=device-width, initial-scale=1">
890
+ <style>
891
+ body { align-items: center; background: #0a0a0a; color: #f6f7f2; display: flex; font: 15px -apple-system, BlinkMacSystemFont, sans-serif; height: 100vh; justify-content: center; margin: 0; }
892
+ main { background: #11130f; border: 1px solid #2c3324; border-radius: 18px; max-width: 560px; padding: 28px; }
893
+ h1 { font-size: 22px; margin: 0 0 10px; }
894
+ p { color: #b9c3b0; line-height: 1.5; margin: 0; }
895
+ code { color: #b2e969; }
896
+ </style>
897
+ </head>
898
+ <body><main><h1>\(escapedTitle)</h1><p>\(escapedDetail)</p></main></body>
899
+ </html>
900
+ """
901
+ webView.loadHTMLString(html, baseURL: nil)
902
+ }
903
+
904
+ private func escapeHTML(_ value: String) -> String {
905
+ return value
906
+ .replacingOccurrences(of: "&", with: "&amp;")
907
+ .replacingOccurrences(of: "<", with: "&lt;")
908
+ .replacingOccurrences(of: ">", with: "&gt;")
909
+ .replacingOccurrences(of: "\"", with: "&quot;")
910
+ .replacingOccurrences(of: "'", with: "&#39;")
911
+ }
912
+ }
913
+
914
+ let app = NSApplication.shared
915
+ let delegate = AppDelegate()
916
+ app.delegate = delegate
917
+ app.setActivationPolicy(.regular)
918
+ app.run()
919
+ SWIFT
920
+ return 0
921
+ }
922
+
923
+ compile_webview_app() {
924
+ local resources_dir="$1"
925
+ local macos_dir="$2"
926
+ local swift_source="${resources_dir}/aidevops-gui.swift"
927
+
928
+ env MACOSX_DEPLOYMENT_TARGET=13.0 swiftc -framework AppKit -framework WebKit "$swift_source" -o "${macos_dir}/aidevops-gui"
929
+ chmod 755 "${macos_dir}/aidevops-gui"
930
+ return 0
931
+ }
932
+
933
+ write_app_bundle() {
934
+ local root="$1"
935
+ local app_dir="$2"
936
+ local app_path="${app_dir}/${APP_NAME}"
937
+ local contents_dir="${app_path}/Contents"
938
+ local macos_dir="${contents_dir}/MacOS"
939
+ local resources_dir="${contents_dir}/Resources"
940
+ local version=""
941
+
942
+ version="$(app_version "$root")"
943
+
944
+ mkdir -p "$macos_dir" "$resources_dir"
945
+ cat > "${contents_dir}/Info.plist" <<PLIST
946
+ <?xml version="1.0" encoding="UTF-8"?>
947
+ <plist version="1.0">
948
+ <dict>
949
+ <key>CFBundleName</key><string>aidevops</string>
950
+ <key>CFBundleDisplayName</key><string>aidevops</string>
951
+ <key>CFBundleIdentifier</key><string>sh.aidevops.gui</string>
952
+ <key>CFBundleVersion</key><string>${version}</string>
953
+ <key>CFBundleShortVersionString</key><string>${version}</string>
954
+ <key>CFBundleExecutable</key><string>aidevops-gui</string>
955
+ <key>CFBundleIconFile</key><string>aidevops.icns</string>
956
+ <key>LSMinimumSystemVersion</key><string>13.0</string>
957
+ <key>NSAppTransportSecurity</key>
958
+ <dict>
959
+ <key>NSAllowsLocalNetworking</key><true/>
960
+ </dict>
961
+ <key>NSHighResolutionCapable</key><true/>
962
+ </dict>
963
+ </plist>
964
+ PLIST
965
+ write_icon_assets "$resources_dir"
966
+ write_service_helper "$root" "$resources_dir"
967
+ write_webview_source "$resources_dir"
968
+ compile_webview_app "$resources_dir" "$macos_dir"
969
+ printf 'Installed %s\n' "$app_path"
970
+ return 0
971
+ }
972
+
973
+ main() {
974
+ local mode="install"
975
+ local app_dir="$DEFAULT_APP_DIR"
976
+ local root=""
977
+
978
+ while [[ $# -gt 0 ]]; do
979
+ local arg="$1"
980
+ case "$arg" in
981
+ --check)
982
+ mode="check"
983
+ shift
984
+ ;;
985
+ --app-dir)
986
+ if [[ $# -lt 2 ]]; then
987
+ usage >&2
988
+ return 1
989
+ fi
990
+ local next_arg="$2"
991
+ app_dir="$next_arg"
992
+ shift 2
993
+ ;;
994
+ --help|-h)
995
+ usage
996
+ return 0
997
+ ;;
998
+ *)
999
+ usage >&2
1000
+ return 1
1001
+ ;;
1002
+ esac
1003
+ done
1004
+
1005
+ root="$(repo_root)"
1006
+ validate_environment "$root"
1007
+ if [[ "$mode" == "check" ]]; then
1008
+ if ! gui_dependencies_present "$root"; then
1009
+ printf 'GUI dependencies are missing; run bun install --frozen-lockfile in %s\n' "$root" >&2
1010
+ return 1
1011
+ fi
1012
+ printf 'macOS app bundle check passed for %s\n' "$root"
1013
+ return 0
1014
+ fi
1015
+ ensure_gui_dependencies "$root"
1016
+ write_app_bundle "$root" "$app_dir"
1017
+ return 0
1018
+ }
1019
+
1020
+ main "$@"