aidevops 3.21.12 → 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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/aidevops.sh +3 -2
- package/bun.lock +573 -0
- package/package.json +12 -1
- package/packages/gui-api/package.json +9 -0
- package/packages/gui-api/src/app.ts +49 -0
- package/packages/gui-api/src/file-adapter.ts +207 -0
- package/packages/gui-api/src/server.ts +12 -0
- package/packages/gui-api/src/status-adapter-utils.ts +108 -0
- package/packages/gui-api/src/status-adapter.ts +484 -0
- package/packages/gui-api/src/status-local-repos.ts +239 -0
- package/packages/gui-desktop/package.json +6 -0
- package/packages/gui-desktop/scripts/install-macos-app.sh +1020 -0
- package/packages/gui-shared/package.json +9 -0
- package/packages/gui-shared/src/contracts.ts +341 -0
- package/packages/gui-shared/src/fixtures.ts +250 -0
- package/packages/gui-shared/src/index.ts +2 -0
- package/packages/gui-web/index.html +24 -0
- package/packages/gui-web/package.json +25 -0
- package/packages/gui-web/src/App.tsx +269 -0
- package/packages/gui-web/src/AppNavigation.tsx +379 -0
- package/packages/gui-web/src/AppWorkspace.tsx +114 -0
- package/packages/gui-web/src/FileExplorerSurface.tsx +153 -0
- package/packages/gui-web/src/InventorySurfaces.tsx +117 -0
- package/packages/gui-web/src/PathActions.tsx +17 -0
- package/packages/gui-web/src/StatusSurfaces.tsx +396 -0
- package/packages/gui-web/src/app-model.ts +546 -0
- package/packages/gui-web/src/dashboard.ts +79 -0
- package/packages/gui-web/src/main.tsx +24 -0
- package/packages/gui-web/src/status-client.ts +93 -0
- package/packages/gui-web/src/styles.css +1540 -0
- package/packages/gui-web/vite.config.ts +14 -0
- 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: "&")
|
|
907
|
+
.replacingOccurrences(of: "<", with: "<")
|
|
908
|
+
.replacingOccurrences(of: ">", with: ">")
|
|
909
|
+
.replacingOccurrences(of: "\"", with: """)
|
|
910
|
+
.replacingOccurrences(of: "'", with: "'")
|
|
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 "$@"
|