aidevops 3.29.42 → 3.30.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 +1 -1
- package/package.json +1 -1
- package/packages/gui-api/src/app.ts +42 -11
- package/packages/gui-desktop/scripts/install-macos-app.sh +217 -2
- package/packages/gui-web/src/App.tsx +3 -26
- package/packages/gui-web/src/DesktopStatusBar.tsx +28 -0
- package/packages/gui-web/src/GenUiCards.tsx +1 -1
- package/packages/gui-web/src/PulseWorkersSurface.tsx +4 -1
- package/packages/gui-web/src/ScreenshotCaptureNotification.tsx +51 -0
- package/packages/gui-web/src/styles.css +63 -1
- package/setup.sh +1 -1
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ The result: an AI operations platform that manages projects across every busines
|
|
|
58
58
|
[](https://github.com/marcusquinn)
|
|
59
59
|
|
|
60
60
|
<!-- Release & Version Info -->
|
|
61
|
-
[](https://github.com/marcusquinn/aidevops/releases)
|
|
62
62
|
[](https://www.npmjs.com/package/aidevops)
|
|
63
63
|
[](https://github.com/marcusquinn/homebrew-tap)
|
|
64
64
|
[](https://github.com/marcusquinn/aidevops)
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.30.0
|
package/aidevops.sh
CHANGED
package/package.json
CHANGED
|
@@ -122,9 +122,8 @@ export function createGuiApiApp() {
|
|
|
122
122
|
|
|
123
123
|
app.post(PULSE_WORKERS_ACTION_ROUTE_MANIFEST.route, (context) => {
|
|
124
124
|
const action = context.req.param("action") as GuiPulseWorkerActionId;
|
|
125
|
-
const actionCommand = pulseWorkerActionCommands[action];
|
|
126
125
|
|
|
127
|
-
if (
|
|
126
|
+
if (!Object.hasOwn(pulseWorkerActionCommands, action)) {
|
|
128
127
|
return context.json(createEnvelope({
|
|
129
128
|
operation_id: PULSE_WORKERS_ACTION_ROUTE_MANIFEST.operation_id,
|
|
130
129
|
source: { surface: "pulse_workers", authority: "allowlisted local command runner", path_refs: [] },
|
|
@@ -133,6 +132,7 @@ export function createGuiApiApp() {
|
|
|
133
132
|
}), 400);
|
|
134
133
|
}
|
|
135
134
|
|
|
135
|
+
const actionCommand = pulseWorkerActionCommands[action];
|
|
136
136
|
const job = startPulseWorkerActionJob(action, actionCommand.command, actionCommand.target_ref, actionCommand.audit_ref);
|
|
137
137
|
return context.json(createEnvelope({
|
|
138
138
|
operation_id: PULSE_WORKERS_ACTION_ROUTE_MANIFEST.operation_id,
|
|
@@ -199,6 +199,7 @@ export function createGuiApiApp() {
|
|
|
199
199
|
|
|
200
200
|
function startAppActionJob(appId: string, action: GuiAppActionId, command: string[]): GuiAppActionJobSummary {
|
|
201
201
|
const now = new Date().toISOString();
|
|
202
|
+
const redactLine = createOutputLineRedactor();
|
|
202
203
|
const job: GuiAppActionJobSummary = {
|
|
203
204
|
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
204
205
|
app_id: appId,
|
|
@@ -219,11 +220,11 @@ function startAppActionJob(appId: string, action: GuiAppActionId, command: strin
|
|
|
219
220
|
});
|
|
220
221
|
if (child.stdout !== null) {
|
|
221
222
|
const stdoutLines = readline.createInterface({ input: child.stdout, terminal: false });
|
|
222
|
-
stdoutLines.on("line", (line) => appendJobLine(job, line));
|
|
223
|
+
stdoutLines.on("line", (line) => appendJobLine(job, line, redactLine));
|
|
223
224
|
}
|
|
224
225
|
if (child.stderr !== null) {
|
|
225
226
|
const stderrLines = readline.createInterface({ input: child.stderr, terminal: false });
|
|
226
|
-
stderrLines.on("line", (line) => appendJobLine(job, line));
|
|
227
|
+
stderrLines.on("line", (line) => appendJobLine(job, line, redactLine));
|
|
227
228
|
}
|
|
228
229
|
child.on("error", (error) => {
|
|
229
230
|
appendJobLine(job, error.message);
|
|
@@ -240,11 +241,11 @@ function startAppActionJob(appId: string, action: GuiAppActionId, command: strin
|
|
|
240
241
|
return job;
|
|
241
242
|
}
|
|
242
243
|
|
|
243
|
-
function appendJobLine(job: GuiAppActionJobSummary, line: string): void {
|
|
244
|
+
function appendJobLine(job: GuiAppActionJobSummary, line: string, redactLine: (line: string) => string = redactOutputLine): void {
|
|
244
245
|
if (line.length === 0) {
|
|
245
246
|
return;
|
|
246
247
|
}
|
|
247
|
-
job.output.push(
|
|
248
|
+
job.output.push(redactLine(line));
|
|
248
249
|
if (job.output.length > 400) {
|
|
249
250
|
job.output.splice(1, job.output.length - 400);
|
|
250
251
|
}
|
|
@@ -252,6 +253,7 @@ function appendJobLine(job: GuiAppActionJobSummary, line: string): void {
|
|
|
252
253
|
|
|
253
254
|
function startPulseWorkerActionJob(action: GuiPulseWorkerActionId, command: string[], targetRef: string, auditRef: string): GuiPulseWorkerActionJobSummary {
|
|
254
255
|
const now = new Date().toISOString();
|
|
256
|
+
const redactLine = createOutputLineRedactor();
|
|
255
257
|
const job: GuiPulseWorkerActionJobSummary = {
|
|
256
258
|
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
257
259
|
action,
|
|
@@ -265,7 +267,7 @@ function startPulseWorkerActionJob(action: GuiPulseWorkerActionId, command: stri
|
|
|
265
267
|
audit_ref: auditRef,
|
|
266
268
|
};
|
|
267
269
|
pulseWorkerActionJobs.set(job.id, job);
|
|
268
|
-
startBackgroundProcess(command, (line) => appendPulseWorkerJobLine(job, line), (code) => {
|
|
270
|
+
startBackgroundProcess(command, (line) => appendPulseWorkerJobLine(job, line, redactLine), (code) => {
|
|
269
271
|
job.status = code === 0 ? "completed" : "failed";
|
|
270
272
|
job.finished_at = new Date().toISOString();
|
|
271
273
|
job.exit_code = code;
|
|
@@ -274,17 +276,26 @@ function startPulseWorkerActionJob(action: GuiPulseWorkerActionId, command: stri
|
|
|
274
276
|
return job;
|
|
275
277
|
}
|
|
276
278
|
|
|
277
|
-
function appendPulseWorkerJobLine(job: GuiPulseWorkerActionJobSummary, line: string): void {
|
|
279
|
+
function appendPulseWorkerJobLine(job: GuiPulseWorkerActionJobSummary, line: string, redactLine: (line: string) => string = redactOutputLine): void {
|
|
278
280
|
if (line.length === 0) {
|
|
279
281
|
return;
|
|
280
282
|
}
|
|
281
|
-
job.output.push(
|
|
283
|
+
job.output.push(redactLine(line));
|
|
282
284
|
if (job.output.length > 400) {
|
|
283
285
|
job.output.splice(1, job.output.length - 400);
|
|
284
286
|
}
|
|
285
287
|
}
|
|
286
288
|
|
|
287
289
|
function startBackgroundProcess(command: string[], appendLine: (line: string) => void, finish: (code: number) => void): void {
|
|
290
|
+
let finished = false;
|
|
291
|
+
const safeFinish = (code: number): void => {
|
|
292
|
+
if (finished) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
finished = true;
|
|
296
|
+
finish(code);
|
|
297
|
+
};
|
|
298
|
+
|
|
288
299
|
const child = spawn(command[0], command.slice(1), {
|
|
289
300
|
cwd: process.cwd(),
|
|
290
301
|
env: { ...process.env, AIDEVOPS_NON_INTERACTIVE: "true", CLICOLOR_FORCE: "1", FORCE_COLOR: "1", TERM: process.env.TERM ?? "xterm-256color" },
|
|
@@ -298,9 +309,29 @@ function startBackgroundProcess(command: string[], appendLine: (line: string) =>
|
|
|
298
309
|
}
|
|
299
310
|
child.on("error", (error) => {
|
|
300
311
|
appendLine(error.message);
|
|
301
|
-
|
|
312
|
+
safeFinish(127);
|
|
302
313
|
});
|
|
303
|
-
child.on("close", (code) =>
|
|
314
|
+
child.on("close", (code) => safeFinish(code ?? 1));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function createOutputLineRedactor(): (line: string) => string {
|
|
318
|
+
let inPrivateKeyBlock = false;
|
|
319
|
+
|
|
320
|
+
return (line: string): string => {
|
|
321
|
+
if (/-----BEGIN [A-Z ]+ PRIVATE KEY-----/.test(line)) {
|
|
322
|
+
inPrivateKeyBlock = !/-----END [A-Z ]+ PRIVATE KEY-----/.test(line);
|
|
323
|
+
return "[redacted private key]";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (inPrivateKeyBlock) {
|
|
327
|
+
if (/-----END [A-Z ]+ PRIVATE KEY-----/.test(line)) {
|
|
328
|
+
inPrivateKeyBlock = false;
|
|
329
|
+
}
|
|
330
|
+
return "[redacted private key]";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return redactOutputLine(line);
|
|
334
|
+
};
|
|
304
335
|
}
|
|
305
336
|
|
|
306
337
|
function redactOutputLine(line: string): string {
|
|
@@ -341,6 +341,7 @@ final class DraggableTitlebarView: NSView {
|
|
|
341
341
|
final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, NSMenuItemValidation, NSWindowDelegate {
|
|
342
342
|
private var window: NSWindow!
|
|
343
343
|
private var aboutWindow: NSWindow?
|
|
344
|
+
private var titlebarCameraButton: NSButton?
|
|
344
345
|
private var titlebarToggleButton: NSButton?
|
|
345
346
|
private var webView: WKWebView!
|
|
346
347
|
private let apiPort = ProcessInfo.processInfo.environment["AIDEVOPS_GUI_API_PORT"] ?? "8787"
|
|
@@ -408,9 +409,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
408
409
|
return
|
|
409
410
|
}
|
|
410
411
|
|
|
412
|
+
if revealFileURL(url) {
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
|
|
411
416
|
_ = openExternalURL(url)
|
|
412
417
|
}
|
|
413
418
|
|
|
419
|
+
private func revealFileURL(_ url: URL) -> Bool {
|
|
420
|
+
guard url.isFileURL else {
|
|
421
|
+
return false
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
NSWorkspace.shared.activateFileViewerSelecting([url])
|
|
425
|
+
return true
|
|
426
|
+
}
|
|
427
|
+
|
|
414
428
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
415
429
|
guard let url = navigationAction.request.url else {
|
|
416
430
|
decisionHandler(.allow)
|
|
@@ -552,7 +566,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
552
566
|
return webView?.canGoBack ?? false
|
|
553
567
|
case #selector(goForward(_:)):
|
|
554
568
|
return webView?.canGoForward ?? false
|
|
555
|
-
case #selector(reloadPage(_:)), #selector(stopLoading(_:)), #selector(resetZoom(_:)), #selector(zoomIn(_:)), #selector(zoomOut(_:)):
|
|
569
|
+
case #selector(reloadPage(_:)), #selector(stopLoading(_:)), #selector(resetZoom(_:)), #selector(zoomIn(_:)), #selector(zoomOut(_:)), #selector(captureAppScreenshot(_:)), #selector(capturePageScreenshot(_:)):
|
|
556
570
|
return webView != nil
|
|
557
571
|
default:
|
|
558
572
|
return true
|
|
@@ -779,11 +793,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
779
793
|
|
|
780
794
|
overlay.addSubview(toggleButton)
|
|
781
795
|
titlebarToggleButton = toggleButton
|
|
796
|
+
|
|
797
|
+
let cameraButton = NSButton(frame: .zero)
|
|
798
|
+
cameraButton.action = #selector(showScreenshotMenu(_:))
|
|
799
|
+
cameraButton.bezelStyle = .regularSquare
|
|
800
|
+
cameraButton.contentTintColor = defaultButtonAccent
|
|
801
|
+
cameraButton.image = cameraImage()
|
|
802
|
+
cameraButton.imagePosition = .imageOnly
|
|
803
|
+
cameraButton.isBordered = false
|
|
804
|
+
cameraButton.setButtonType(.momentaryChange)
|
|
805
|
+
cameraButton.target = self
|
|
806
|
+
cameraButton.toolTip = "Capture an aidevops screenshot"
|
|
807
|
+
cameraButton.translatesAutoresizingMaskIntoConstraints = false
|
|
808
|
+
|
|
809
|
+
overlay.addSubview(cameraButton)
|
|
810
|
+
titlebarCameraButton = cameraButton
|
|
782
811
|
NSLayoutConstraint.activate([
|
|
783
812
|
toggleButton.leadingAnchor.constraint(equalTo: overlay.leadingAnchor, constant: 92),
|
|
784
813
|
toggleButton.centerYAnchor.constraint(equalTo: overlay.centerYAnchor, constant: 3.5),
|
|
785
814
|
toggleButton.heightAnchor.constraint(equalToConstant: 22),
|
|
786
|
-
toggleButton.widthAnchor.constraint(equalToConstant: 22)
|
|
815
|
+
toggleButton.widthAnchor.constraint(equalToConstant: 22),
|
|
816
|
+
cameraButton.trailingAnchor.constraint(equalTo: overlay.trailingAnchor, constant: -92),
|
|
817
|
+
cameraButton.centerYAnchor.constraint(equalTo: overlay.centerYAnchor, constant: 3.5),
|
|
818
|
+
cameraButton.heightAnchor.constraint(equalToConstant: 22),
|
|
819
|
+
cameraButton.widthAnchor.constraint(equalToConstant: 22)
|
|
787
820
|
])
|
|
788
821
|
|
|
789
822
|
return overlay
|
|
@@ -791,6 +824,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
791
824
|
|
|
792
825
|
private func updateTitlebarAccent(hue: CGFloat) {
|
|
793
826
|
let normalizedHue = min(359, max(0, hue))
|
|
827
|
+
titlebarCameraButton?.contentTintColor = accentColor(hueDegrees: normalizedHue, alpha: 0.9)
|
|
794
828
|
titlebarToggleButton?.contentTintColor = accentColor(hueDegrees: normalizedHue, alpha: 0.9)
|
|
795
829
|
}
|
|
796
830
|
|
|
@@ -866,10 +900,191 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
866
900
|
return image
|
|
867
901
|
}
|
|
868
902
|
|
|
903
|
+
private func cameraImage() -> NSImage {
|
|
904
|
+
let image = NSImage(size: NSSize(width: 20, height: 20), flipped: false) { _ in
|
|
905
|
+
let body = NSBezierPath(roundedRect: NSRect(x: 3, y: 6, width: 14, height: 10), xRadius: 2.7, yRadius: 2.7)
|
|
906
|
+
body.lineWidth = 2
|
|
907
|
+
NSColor.labelColor.setStroke()
|
|
908
|
+
body.stroke()
|
|
909
|
+
|
|
910
|
+
let prism = NSBezierPath(roundedRect: NSRect(x: 6, y: 14, width: 5, height: 2.3), xRadius: 1, yRadius: 1)
|
|
911
|
+
prism.lineWidth = 1.8
|
|
912
|
+
prism.stroke()
|
|
913
|
+
|
|
914
|
+
let lens = NSBezierPath(ovalIn: NSRect(x: 7.1, y: 8.1, width: 5.8, height: 5.8))
|
|
915
|
+
lens.lineWidth = 1.8
|
|
916
|
+
lens.stroke()
|
|
917
|
+
|
|
918
|
+
return true
|
|
919
|
+
}
|
|
920
|
+
image.isTemplate = true
|
|
921
|
+
return image
|
|
922
|
+
}
|
|
923
|
+
|
|
869
924
|
@objc private func toggleMachineRail(_ sender: Any?) {
|
|
870
925
|
webView.evaluateJavaScript("window.dispatchEvent(new CustomEvent('aidevops:toggle-machine-rail')); true;", completionHandler: nil)
|
|
871
926
|
}
|
|
872
927
|
|
|
928
|
+
@objc private func showScreenshotMenu(_ sender: Any?) {
|
|
929
|
+
guard let button = sender as? NSButton else {
|
|
930
|
+
return
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
let menu = NSMenu(title: "Screenshots")
|
|
934
|
+
menu.addItem(menuItem("Screenshot App", action: #selector(captureAppScreenshot(_:)), target: self))
|
|
935
|
+
menu.addItem(menuItem("Screenshot Page", action: #selector(capturePageScreenshot(_:)), target: self))
|
|
936
|
+
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 3), in: button)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
@objc private func captureAppScreenshot(_ sender: Any?) {
|
|
940
|
+
guard let contentView = window.contentView else {
|
|
941
|
+
return
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
let bounds = contentView.bounds
|
|
945
|
+
guard let representation = contentView.bitmapImageRepForCachingDisplay(in: bounds) else {
|
|
946
|
+
return
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
contentView.cacheDisplay(in: bounds, to: representation)
|
|
950
|
+
let image = NSImage(size: bounds.size)
|
|
951
|
+
image.addRepresentation(representation)
|
|
952
|
+
saveScreenshot(image: image, nameComponent: "window")
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
@objc private func capturePageScreenshot(_ sender: Any?) {
|
|
956
|
+
let script = """
|
|
957
|
+
(() => {
|
|
958
|
+
const el = document.querySelector('.workspace-scroll');
|
|
959
|
+
const title = document.querySelector('.header-title h1')?.textContent || document.title || 'page';
|
|
960
|
+
if (!el) return null;
|
|
961
|
+
const rect = el.getBoundingClientRect();
|
|
962
|
+
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height, scrollHeight: el.scrollHeight, title, originalScrollTop: el.scrollTop };
|
|
963
|
+
})();
|
|
964
|
+
"""
|
|
965
|
+
|
|
966
|
+
webView.evaluateJavaScript(script) { [weak self] result, _ in
|
|
967
|
+
guard let self = self, let info = result as? [String: Any] else {
|
|
968
|
+
return
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
self.captureScrollableWorkspace(info: info)
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
private func captureScrollableWorkspace(info: [String: Any]) {
|
|
976
|
+
guard let x = info["x"] as? NSNumber,
|
|
977
|
+
let y = info["y"] as? NSNumber,
|
|
978
|
+
let width = info["width"] as? NSNumber,
|
|
979
|
+
let height = info["height"] as? NSNumber,
|
|
980
|
+
let scrollHeight = info["scrollHeight"] as? NSNumber else {
|
|
981
|
+
return
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
let viewportHeight = max(1, CGFloat(height.doubleValue))
|
|
985
|
+
let contentHeight = max(viewportHeight, CGFloat(scrollHeight.doubleValue))
|
|
986
|
+
let contentWidth = max(1, CGFloat(width.doubleValue))
|
|
987
|
+
let captureRect = CGRect(x: CGFloat(x.doubleValue), y: CGFloat(y.doubleValue), width: contentWidth, height: viewportHeight)
|
|
988
|
+
let pageName = sanitizeFilenameComponent((info["title"] as? String) ?? "page")
|
|
989
|
+
let originalScrollTop = CGFloat((info["originalScrollTop"] as? NSNumber)?.doubleValue ?? 0)
|
|
990
|
+
let composite = NSImage(size: NSSize(width: contentWidth, height: contentHeight))
|
|
991
|
+
captureWorkspaceChunk(rect: captureRect, contentHeight: contentHeight, viewportHeight: viewportHeight, originalScrollTop: originalScrollTop, targetOffset: 0, pageName: pageName, composite: composite, capturedOffsets: Set<Int>())
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
private func captureWorkspaceChunk(rect: CGRect, contentHeight: CGFloat, viewportHeight: CGFloat, originalScrollTop: CGFloat, targetOffset: CGFloat, pageName: String, composite: NSImage, capturedOffsets: Set<Int>) {
|
|
995
|
+
let scrollScript = """
|
|
996
|
+
(() => {
|
|
997
|
+
const el = document.querySelector('.workspace-scroll');
|
|
998
|
+
if (!el) return null;
|
|
999
|
+
el.scrollTop = \(Int(targetOffset));
|
|
1000
|
+
return el.scrollTop;
|
|
1001
|
+
})();
|
|
1002
|
+
"""
|
|
1003
|
+
|
|
1004
|
+
webView.evaluateJavaScript(scrollScript) { [weak self] result, _ in
|
|
1005
|
+
guard let self = self else {
|
|
1006
|
+
return
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
let actualOffset = CGFloat((result as? NSNumber)?.doubleValue ?? Double(targetOffset))
|
|
1010
|
+
let offsetKey = Int(actualOffset.rounded())
|
|
1011
|
+
var nextCapturedOffsets = capturedOffsets
|
|
1012
|
+
if capturedOffsets.contains(offsetKey) {
|
|
1013
|
+
self.restoreWorkspaceScroll(originalScrollTop)
|
|
1014
|
+
self.saveScreenshot(image: composite, nameComponent: pageName)
|
|
1015
|
+
return
|
|
1016
|
+
}
|
|
1017
|
+
nextCapturedOffsets.insert(offsetKey)
|
|
1018
|
+
|
|
1019
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
|
|
1020
|
+
let configuration = WKSnapshotConfiguration()
|
|
1021
|
+
configuration.rect = rect
|
|
1022
|
+
self.webView.takeSnapshot(with: configuration) { snapshot, _ in
|
|
1023
|
+
if let snapshot = snapshot {
|
|
1024
|
+
composite.lockFocus()
|
|
1025
|
+
snapshot.draw(in: NSRect(x: 0, y: contentHeight - actualOffset - viewportHeight, width: rect.width, height: viewportHeight), from: .zero, operation: .copy, fraction: 1)
|
|
1026
|
+
composite.unlockFocus()
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if actualOffset + viewportHeight >= contentHeight - 1 {
|
|
1030
|
+
self.restoreWorkspaceScroll(originalScrollTop)
|
|
1031
|
+
self.saveScreenshot(image: composite, nameComponent: pageName)
|
|
1032
|
+
} else {
|
|
1033
|
+
let nextOffset = min(contentHeight - viewportHeight, actualOffset + viewportHeight)
|
|
1034
|
+
self.captureWorkspaceChunk(rect: rect, contentHeight: contentHeight, viewportHeight: viewportHeight, originalScrollTop: originalScrollTop, targetOffset: nextOffset, pageName: pageName, composite: composite, capturedOffsets: nextCapturedOffsets)
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
private func restoreWorkspaceScroll(_ scrollTop: CGFloat) {
|
|
1042
|
+
webView.evaluateJavaScript("document.querySelector('.workspace-scroll')?.scrollTo({ top: \(Int(scrollTop)), behavior: 'instant' }); true;", completionHandler: nil)
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
private func saveScreenshot(image: NSImage, nameComponent: String) {
|
|
1046
|
+
guard let tiff = image.tiffRepresentation,
|
|
1047
|
+
let bitmap = NSBitmapImageRep(data: tiff),
|
|
1048
|
+
let data = bitmap.representation(using: .png, properties: [:]) else {
|
|
1049
|
+
return
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
let directory = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Screenshots", isDirectory: true)
|
|
1053
|
+
do {
|
|
1054
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
1055
|
+
let fileURL = directory.appendingPathComponent("aidevops-app-screenshot-\(sanitizeFilenameComponent(nameComponent))-\(screenshotTimestamp()).png")
|
|
1056
|
+
try data.write(to: fileURL, options: .atomic)
|
|
1057
|
+
NSPasteboard.general.clearContents()
|
|
1058
|
+
NSPasteboard.general.setString(fileURL.path, forType: .string)
|
|
1059
|
+
notifyScreenshotCaptured(fileURL: fileURL)
|
|
1060
|
+
} catch {
|
|
1061
|
+
NSSound.beep()
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
private func screenshotTimestamp() -> String {
|
|
1066
|
+
let formatter = DateFormatter()
|
|
1067
|
+
formatter.dateFormat = "yyMMdd-HHmmss"
|
|
1068
|
+
return formatter.string(from: Date())
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
private func sanitizeFilenameComponent(_ value: String) -> String {
|
|
1072
|
+
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
|
1073
|
+
let mapped = value.lowercased().unicodeScalars.map { allowed.contains($0) ? String($0) : "-" }.joined()
|
|
1074
|
+
let collapsed = mapped.split(separator: "-").joined(separator: "-")
|
|
1075
|
+
return collapsed.isEmpty ? "page" : collapsed
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
private func notifyScreenshotCaptured(fileURL: URL) {
|
|
1079
|
+
guard let payloadData = try? JSONSerialization.data(withJSONObject: ["path": fileURL.path, "url": fileURL.absoluteString], options: []),
|
|
1080
|
+
let payload = String(data: payloadData, encoding: .utf8) else {
|
|
1081
|
+
return
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
let script = "window.dispatchEvent(new CustomEvent('aidevops:screenshot-captured', { detail: \(payload) })); true;"
|
|
1085
|
+
webView.evaluateJavaScript(script, completionHandler: nil)
|
|
1086
|
+
}
|
|
1087
|
+
|
|
873
1088
|
private func saveMainWindowFrame() {
|
|
874
1089
|
guard window != nil else {
|
|
875
1090
|
return
|
|
@@ -4,7 +4,9 @@ import { MachineRail, Sidebar } from "./AppNavigation";
|
|
|
4
4
|
import { Workspace } from "./AppWorkspace";
|
|
5
5
|
import type { ContrastPreference, ConversationMode, FontPreference, FontSizePreference, ShellMode, SurfaceId, ThemePreference } from "./app-model";
|
|
6
6
|
import { DEFAULT_ACCENT_HUE, DEFAULT_CONTRAST, DEFAULT_FONT, DEFAULT_FONT_SIZE, fileRootBySurface, findSurface, findSurfaceSectionLabel, fontFamilyForPreference, fontSizeForPreference, getSystemTheme, isContrastPreference, isFontPreference, isFontSizePreference } from "./app-model";
|
|
7
|
+
import { DesktopStatusBar } from "./DesktopStatusBar";
|
|
7
8
|
import { type NavigationHistory, nextHistoryIndex, useNavigationHistoryKeyboard } from "./navigation-history";
|
|
9
|
+
import { ScreenshotCaptureNotificationHost } from "./ScreenshotCaptureNotification";
|
|
8
10
|
import { fetchStatus, mockedStatus } from "./status-client";
|
|
9
11
|
|
|
10
12
|
interface WebKitBridgeWindow extends Window {
|
|
@@ -214,6 +216,7 @@ export function App(): ReactElement {
|
|
|
214
216
|
return (
|
|
215
217
|
<main className={machineRailVisible ? "app-shell" : "app-shell machine-rail-collapsed"}>
|
|
216
218
|
<div className="desktop-titlebar-tagline" aria-hidden="true">Your data protected. Your systems managed. Your creations published.</div>
|
|
219
|
+
<ScreenshotCaptureNotificationHost />
|
|
217
220
|
{machineRailVisible ? <MachineRail machine={status.data.machine} /> : null}
|
|
218
221
|
<Sidebar
|
|
219
222
|
activeSurface={activeSurface}
|
|
@@ -423,29 +426,3 @@ function readStoredBoolean(storage: ReadableAppearanceStorage | undefined, key:
|
|
|
423
426
|
|
|
424
427
|
return fallback;
|
|
425
428
|
}
|
|
426
|
-
|
|
427
|
-
function DesktopStatusBar({ status }: { status: GuiStatusData }): ReactElement {
|
|
428
|
-
const version = status.update.installed_version !== "unknown" ? status.update.installed_version : status.aidevops_version;
|
|
429
|
-
const versionLabel = version === "unknown" || version.startsWith("v") ? version : `v${version}`;
|
|
430
|
-
const needsUpdate = status.setup_targets.filter((target) => target.needs_update).length + status.ai_apps.filter((app) => app.needs_update).length;
|
|
431
|
-
const oauthAccounts = status.oauth_pool.providers.reduce((total, provider) => total + provider.total, 0);
|
|
432
|
-
const localRepoCount = status.local_repos.total || status.local_repos.repos.length;
|
|
433
|
-
const remoteRepoCount = status.repos.total || status.repos.repos.length;
|
|
434
|
-
const statusLabel = status.update.restart_required ? "Restart required" : "Ready";
|
|
435
|
-
const vaultLabel = status.vault?.unlocked ? "Vault unlocked" : `Vault ${status.vault?.status ?? "unknown"}`;
|
|
436
|
-
|
|
437
|
-
return (
|
|
438
|
-
<div className="desktop-status-bar" role="status">
|
|
439
|
-
<span className={status.update.restart_required ? "status-dot warn" : "status-dot"} aria-hidden="true" />
|
|
440
|
-
<strong>{statusLabel}</strong>
|
|
441
|
-
<span>Read-only local GUI</span>
|
|
442
|
-
<span>{versionLabel}</span>
|
|
443
|
-
<span>{localRepoCount} local repos</span>
|
|
444
|
-
<span>{remoteRepoCount} remote repos</span>
|
|
445
|
-
<span>{status.secrets.length} secrets</span>
|
|
446
|
-
<span>{vaultLabel}</span>
|
|
447
|
-
<span>{oauthAccounts} provider accounts</span>
|
|
448
|
-
<span>{needsUpdate} need update</span>
|
|
449
|
-
</div>
|
|
450
|
-
);
|
|
451
|
-
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { GuiStatusData } from "@aidevops/gui-shared";
|
|
2
|
+
import type { ReactElement } from "react";
|
|
3
|
+
|
|
4
|
+
export function DesktopStatusBar({ status }: { status: GuiStatusData }): ReactElement {
|
|
5
|
+
const version = status.update.installed_version !== "unknown" ? status.update.installed_version : status.aidevops_version;
|
|
6
|
+
const versionLabel = version === "unknown" || version.startsWith("v") ? version : `v${version}`;
|
|
7
|
+
const needsUpdate = status.setup_targets.filter((target) => target.needs_update).length + status.ai_apps.filter((app) => app.needs_update).length;
|
|
8
|
+
const oauthAccounts = status.oauth_pool.providers.reduce((total, provider) => total + provider.total, 0);
|
|
9
|
+
const localRepoCount = status.local_repos.total || status.local_repos.repos.length;
|
|
10
|
+
const remoteRepoCount = status.repos.total || status.repos.repos.length;
|
|
11
|
+
const statusLabel = status.update.restart_required ? "Restart required" : "Ready";
|
|
12
|
+
const vaultLabel = status.vault?.unlocked ? "Vault unlocked" : `Vault ${status.vault?.status ?? "unknown"}`;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="desktop-status-bar" role="status">
|
|
16
|
+
<span className={status.update.restart_required ? "status-dot warn" : "status-dot"} aria-hidden="true" />
|
|
17
|
+
<strong>{statusLabel}</strong>
|
|
18
|
+
<span>Read-only local GUI</span>
|
|
19
|
+
<span>{versionLabel}</span>
|
|
20
|
+
<span>{localRepoCount} local repos</span>
|
|
21
|
+
<span>{remoteRepoCount} remote repos</span>
|
|
22
|
+
<span>{status.secrets.length} secrets</span>
|
|
23
|
+
<span>{vaultLabel}</span>
|
|
24
|
+
<span>{oauthAccounts} provider accounts</span>
|
|
25
|
+
<span>{needsUpdate} need update</span>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -16,7 +16,7 @@ function DevOpsCard({ validation }: { validation: GuiTamboValidationResult }) {
|
|
|
16
16
|
const entries = Object.entries(props).filter(([key]) => key !== "title" && key !== "disabled");
|
|
17
17
|
|
|
18
18
|
return (
|
|
19
|
-
<section className="genui-card" data-genui-component={validation.component ?? "unknown"} aria-label={`${validation.component} ${text.devOpsCard}`}>
|
|
19
|
+
<section className="genui-card" data-genui-component={validation.component ?? "unknown"} aria-label={`${validation.component ?? "unknown"} ${text.devOpsCard}`}>
|
|
20
20
|
<header>
|
|
21
21
|
<p className="eyebrow">{text.tamboGenUiReadOnly}</p>
|
|
22
22
|
<h3>{stringProp(props.title) ?? validation.component}</h3>
|
|
@@ -266,7 +266,10 @@ function PulseDrilldownPanel({ event }: { event: PulseEvent | undefined }): Reac
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
function relatedFindings(event: PulseEvent): string[] {
|
|
269
|
-
return event.drilldown_sections?.filter((section) =>
|
|
269
|
+
return event.drilldown_sections?.filter((section) => {
|
|
270
|
+
const label = section.label?.toLowerCase();
|
|
271
|
+
return label?.includes("systemic") || label?.includes("verification");
|
|
272
|
+
}).map((section) => `${section.label}: ${section.body}`) ?? [];
|
|
270
273
|
}
|
|
271
274
|
|
|
272
275
|
function buildFilterGroups(pulse: GuiStatusData["pulse_workers"]): PulseFilterGroup[] {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type ReactElement, useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ScreenshotCapturedDetail {
|
|
4
|
+
path: string;
|
|
5
|
+
url: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface WebKitExternalLinkWindow extends Window {
|
|
9
|
+
webkit?: {
|
|
10
|
+
messageHandlers?: {
|
|
11
|
+
externalLink?: {
|
|
12
|
+
postMessage: (href: string) => void;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ScreenshotCaptureNotification({ notification, onDismiss }: { notification: ScreenshotCapturedDetail; onDismiss: () => void }): ReactElement {
|
|
19
|
+
const revealScreenshot = () => {
|
|
20
|
+
const webkit = (window as WebKitExternalLinkWindow).webkit;
|
|
21
|
+
webkit?.messageHandlers?.externalLink?.postMessage(notification.url);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="screenshot-capture-notification" role="status">
|
|
26
|
+
<div>
|
|
27
|
+
<strong>Screenshot captured</strong>
|
|
28
|
+
<span>Saved to <button onClick={revealScreenshot} type="button">{notification.path}</button>; path copied to clipboard.</span>
|
|
29
|
+
</div>
|
|
30
|
+
<button aria-label="Dismiss screenshot notification" onClick={onDismiss} type="button">×</button>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ScreenshotCaptureNotificationHost(): ReactElement | null {
|
|
36
|
+
const [screenshotNotification, setScreenshotNotification] = useState<ScreenshotCapturedDetail | undefined>();
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const showScreenshotNotification = (event: Event) => {
|
|
40
|
+
const detail = (event as CustomEvent<Partial<ScreenshotCapturedDetail>>).detail;
|
|
41
|
+
if (detail !== undefined && typeof detail.path === "string" && typeof detail.url === "string") {
|
|
42
|
+
setScreenshotNotification({ path: detail.path, url: detail.url });
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
window.addEventListener("aidevops:screenshot-captured", showScreenshotNotification);
|
|
47
|
+
return () => window.removeEventListener("aidevops:screenshot-captured", showScreenshotNotification);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
return screenshotNotification ? <ScreenshotCaptureNotification notification={screenshotNotification} onDismiss={() => setScreenshotNotification(undefined)} /> : null;
|
|
51
|
+
}
|
|
@@ -218,7 +218,7 @@ body {
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
:root[data-theme="dark"] body {
|
|
221
|
-
background:
|
|
221
|
+
background-image:
|
|
222
222
|
radial-gradient(ellipse 78% 64% at top left,
|
|
223
223
|
hsl(var(--accent-hue) 96% 22% / 62%) 0%,
|
|
224
224
|
hsl(var(--accent-hue) 92% 18% / 34%) 34%,
|
|
@@ -298,6 +298,68 @@ code {
|
|
|
298
298
|
z-index: 20;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
.screenshot-capture-notification {
|
|
302
|
+
align-items: center;
|
|
303
|
+
background: color-mix(in srgb, var(--surface-elevated) 94%, transparent);
|
|
304
|
+
border: 1px solid var(--border-accent);
|
|
305
|
+
border-radius: 999px;
|
|
306
|
+
box-shadow: var(--glass-panel-shadow);
|
|
307
|
+
color: var(--text-secondary);
|
|
308
|
+
display: flex;
|
|
309
|
+
gap: 12px;
|
|
310
|
+
left: 50%;
|
|
311
|
+
max-width: min(720px, calc(100vw - 48px));
|
|
312
|
+
padding: 8px 10px 8px 14px;
|
|
313
|
+
position: fixed;
|
|
314
|
+
top: calc(var(--desktop-titlebar-height) + 10px);
|
|
315
|
+
transform: translateX(-50%);
|
|
316
|
+
z-index: 40;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.screenshot-capture-notification > div {
|
|
320
|
+
display: grid;
|
|
321
|
+
gap: 1px;
|
|
322
|
+
min-width: 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.screenshot-capture-notification strong {
|
|
326
|
+
color: var(--text-primary);
|
|
327
|
+
font-size: 0.78rem;
|
|
328
|
+
line-height: 1.2;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.screenshot-capture-notification span {
|
|
332
|
+
font-size: 0.74rem;
|
|
333
|
+
line-height: 1.25;
|
|
334
|
+
overflow: hidden;
|
|
335
|
+
text-overflow: ellipsis;
|
|
336
|
+
white-space: nowrap;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.screenshot-capture-notification button {
|
|
340
|
+
background: transparent;
|
|
341
|
+
border: 0;
|
|
342
|
+
color: var(--accent);
|
|
343
|
+
font: inherit;
|
|
344
|
+
padding: 0;
|
|
345
|
+
text-decoration: underline;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.screenshot-capture-notification > button {
|
|
349
|
+
align-items: center;
|
|
350
|
+
background: var(--surface-muted);
|
|
351
|
+
border: 1px solid transparent;
|
|
352
|
+
border-radius: 999px;
|
|
353
|
+
color: var(--text-secondary);
|
|
354
|
+
display: inline-flex;
|
|
355
|
+
flex: 0 0 auto;
|
|
356
|
+
font-size: 1rem;
|
|
357
|
+
height: 24px;
|
|
358
|
+
justify-content: center;
|
|
359
|
+
line-height: 1;
|
|
360
|
+
width: 24px;
|
|
361
|
+
}
|
|
362
|
+
|
|
301
363
|
.machine-rail,
|
|
302
364
|
.app-sidebar,
|
|
303
365
|
.app-inset {
|
package/setup.sh
CHANGED
|
@@ -12,7 +12,7 @@ shopt -s inherit_errexit 2>/dev/null || true
|
|
|
12
12
|
# AI Assistant Server Access Framework Setup Script
|
|
13
13
|
# Helps developers set up the framework for their infrastructure
|
|
14
14
|
#
|
|
15
|
-
# Version: 3.
|
|
15
|
+
# Version: 3.30.0
|
|
16
16
|
#
|
|
17
17
|
# Quick Install:
|
|
18
18
|
# npm install -g aidevops && aidevops update (recommended)
|