Files
hermes-agent/scripts/desktop-update/update-panel.js
ethernet 79e6643ee3 fix(desktop-update): center the panel labels and make linger a real sleep
wrappingLabel's alignment must reach the cell — setAlignment on the
field alone left the title and stage line left-aligned inside
full-width frames ('Updating Hermes' starting at center-looking-box
left edge). Labels now get full-width frames and cell-level centering.

linger() also switched from a runloop pump to a blocking
NSThread.sleepForTimeInterval: with the animation stopped there are no
timer sources to service, so the pump returned immediately (terminal
states lived <1s instead of the designed 1.5s/15s).
2026-09-18 01:17:48 -04:00

272 lines
9.4 KiB
JavaScript

/*
* Clone of ui.html for systems without chromium.
*
* /usr/bin/osascript -l JavaScript update-panel.js <status-file>
*
* Must remain here, next to posix.sh.
* The desktop spawns this directory as-is (see resolvePosixScriptHandoff) and the script references it via
* its own path.
*/
ObjC.import('AppKit')
ObjC.import('QuartzCore') // CACurrentMediaTime
// ── Fourier Flow — constants and math ported verbatim from ui.html ──────────
// Keep in sync with ui.html / apps/desktop/src/components/ui/loader.tsx:
// durationMs 2200, particleCount 92, pulseDurationMs 2000, strokeWidth 4.2,
// trailSpan 0.31, PATH_STEPS 240, viewBox 0 0 100 100 (center 50,50).
const TWO_PI = Math.PI * 2
const CURVE = {
durationMs: 2200,
particleCount: 92,
pulseDurationMs: 2000,
strokeWidth: 4.2,
trailSpan: 0.31,
point(progress, detailScale) {
const t = progress * TWO_PI
const mix = 1 + detailScale * 0.16
const x = 17 * Math.cos(t) + 7.5 * Math.cos(3 * t + 0.6 * mix) + 3.2 * Math.sin(5 * t - 0.4)
const y = 15 * Math.sin(t) + 8.2 * Math.sin(2 * t + 0.25) - 4.2 * Math.cos(4 * t - 0.5 * mix)
return { x: 50 + x, y: 50 + y }
}
}
const PATH_STEPS = 240
const norm = progress => ((progress % 1) + 1) % 1
function detailScaleFor (time, phaseOffset) {
const p = ((time + phaseOffset * CURVE.pulseDurationMs) % CURVE.pulseDurationMs) / CURVE.pulseDurationMs
return 0.52 + ((Math.sin(p * TWO_PI + 0.55) + 1) / 2) * 0.48
}
function particleFor (index, progress, detailScale) {
const tail = index / (CURVE.particleCount - 1)
const { x, y } = CURVE.point(norm(progress - tail * CURVE.trailSpan), detailScale)
const fade = (1 - tail) ** 0.56
return { x, y, opacity: 0.04 + fade * 0.96, radius: 0.9 + fade * 2.7 }
}
// Render one animation frame into an 80x80 NSImage (the JS analog of the
// page's <svg> repaint). AppKit is bottom-left origin; the curve math is
// top-left like SVG, so flip Y around the 50,50 center — the math itself is
// untouched.
function renderLoaderFrame (timeMs, phaseOffset, fg) {
const size = 80
const img = $.NSImage.alloc.initWithSize($.NSMakeSize(size, size))
img.lockFocus
const progress = norm(timeMs / CURVE.durationMs)
const detailScale = detailScaleFor(timeMs, phaseOffset)
const ghost = fg.colorWithAlphaComponent(0.1)
ghost.set
const path = $.NSBezierPath.bezierPath
for (let i = 0; i <= PATH_STEPS; i++) {
const { x, y } = CURVE.point(i / PATH_STEPS, detailScale)
const px = x / 100 * size
const py = size - y / 100 * size
if (i === 0) path.moveToPoint($.NSMakePoint(px, py))
else path.lineToPoint($.NSMakePoint(px, py))
}
path.setLineWidth(CURVE.strokeWidth)
path.setLineCapStyle($.NSRoundLineCapStyle)
path.setLineJoinStyle($.NSRoundLineJoinStyle)
path.stroke
for (let index = 0; index < CURVE.particleCount; index++) {
const p = particleFor(index, progress, detailScale)
const px = p.x / 100 * size
const py = size - p.y / 100 * size
fg.colorWithAlphaComponent(p.opacity).set
$.NSBezierPath.bezierPathWithOvalInRect(
$.NSMakeRect(px - p.radius, py - p.radius, p.radius * 2, p.radius * 2)
).fill
}
img.unlockFocus
return img
}
function isDarkAppearance () {
const app = $.NSApplication.sharedApplication
const match = app.effectiveAppearance.bestMatchFromAppearancesWithNames(
$(['NSAppearanceNameAqua', 'NSAppearanceNameDarkAqua'])
)
return ObjC.unwrap(match) === 'NSAppearanceNameDarkAqua'
}
// ui.html: light fg #1a1a1a; dark #d6d6d6 on #232323.
function colors () {
return isDarkAppearance()
? {
bg: $.NSColor.colorWithCalibratedRedGreenBlueAlpha(35 / 255, 35 / 255, 35 / 255, 1),
fg: $.NSColor.colorWithCalibratedRedGreenBlueAlpha(214 / 255, 214 / 255, 214 / 255, 1)
}
: {
bg: $.NSColor.whiteColor,
fg: $.NSColor.colorWithCalibratedRedGreenBlueAlpha(26 / 255, 26 / 255, 26 / 255, 1)
}
}
function readStatus (path) {
// write_status emits a flat, one-line JSON pair we control, so a missing
// or malformed file simply yields undefined (no update this tick). The
// writer's atomic mv guarantees we never observe a partial file.
try {
const data = $.NSString.stringWithContentsOfFileEncodingError(path, $.NSUTF8StringEncoding, null)
if (data.js === '') return undefined
const state = JSON.parse(ObjC.unwrap(data))
if (!state || typeof state.status !== 'string') return undefined
return state
} catch (_) {
return undefined
}
}
function wrappedLabel (text, font, color, frame) {
const label = $.NSTextField.wrappingLabelWithString(text)
label.font = font
if (color) label.textColor = color
label.editable = false
label.bordered = false
label.drawsBackground = false
// Alignment must reach the cell: setAlignment on the field alone does not
// propagate for wrapping labels, and left-aligned text inside a full-width
// frame reads as "centered box, left-aligned text".
label.alignment = 1
label.cell.alignment = 1
label.frame = frame
return label
}
function run (argv) {
if (argv.length < 1) throw new Error('usage: osascript -l JavaScript update-panel.js <status-file>')
const statusPath = argv[0]
const app = $.NSApplication.sharedApplication
// Accessory: no Dock icon — this is a status panel, not an app.
app.setActivationPolicy($.NSApplicationActivationPolicyAccessory)
const { bg, fg } = colors()
// 280x320 like the Chrome --window-size the shim uses.
const styleMask =
$.NSWindowStyleMaskTitled |
$.NSWindowStyleMaskClosable
const win = $.NSWindow.alloc.initWithContentRectStyleMaskBackingDefer(
$.NSMakeRect(0, 0, 280, 320),
styleMask,
$.NSBackingStoreBuffered,
false
)
// Don't allow closing while Hermes is updating.
const closeButton = win.standardWindowButton($.NSWindowCloseButton)
closeButton.enabled = false
win.title = 'Hermes'
win.center
win.releasedWhenClosed = false
win.backgroundColor = bg
const content = win.contentView
// Loader: 80x80, centered horizontally, near the top (AppKit Y-up).
const loaderView = $.NSImageView.alloc.initWithFrame($.NSMakeRect(100, 226, 80, 80))
loaderView.imageScaling = $.NSImageScaleProportionallyUpOrDown
content.addSubview(loaderView)
const title = wrappedLabel('Updating Hermes', $.NSFont.systemFontOfSize(18), fg,
$.NSMakeRect(0, 178, 280, 26))
const line = wrappedLabel('Hermes will open once done.', $.NSFont.systemFontOfSize(12), null,
$.NSMakeRect(24, 118, 232, 54))
content.addSubview(title)
content.addSubview(line)
win.makeKeyAndOrderFront(null)
app.activateIgnoringOtherApps(true)
// Elapsed clock = when the panel started, like serve-ui.py's started_at.
// Random phase like the page's Math.random() phaseOffset.
const startedAtMs = $.CACurrentMediaTime() * 1000
const phaseOffset = Math.random()
const runloop = $.NSRunLoop.currentRunLoop
let everPublished = false
let settled = null // null = running; else 'done' | 'manual' | 'error'
let message = ''
while (settled === null) {
const state = readStatus(statusPath)
if (state) {
everPublished = true
if (state.message) {
message = String(state.message)
line.stringValue = message
}
if (['done', 'manual', 'error'].includes(state.status)) settled = state.status
} else if (everPublished) {
// The status file vanished after having existed: the shim removes it
// right after publishing a terminal state and tearing down its UI.
// Waiting forever stranded the panel on its last stage — observed on a
// real update. A disappearance reads as done; an error publish keeps
// its file alive through the 15s leave-window grace, so this cannot
// mask a failure.
settled = 'done'
}
if (settled === null) {
loaderView.image = renderLoaderFrame($.CACurrentMediaTime() * 1000 - startedAtMs, phaseOffset, fg)
const screen = $.NSScreen.mainScreen;
const refreshRate = screen.maximumFramesPerSecond;
const frameInterval = 1.0 / refreshRate;
// Pump the main runloop at the monitor's refresh rate.
runloop.runModeBeforeDate(
$.NSDefaultRunLoopMode,
$.NSDate.dateWithTimeIntervalSinceNow(frameInterval)
);
}
}
closeButton.enabled = true
// Terminal states: swap the loader for the glyph; title/line verbatim
// from ui.html's apply().
loaderView.hidden = true
title.stringValue = settled === 'error' ? 'Failed to update' : 'Update complete'
if (settled === 'done') {
line.stringValue = 'Opening Hermes…\nYou can close this window.'
} else if (settled === 'manual') {
line.stringValue = message || 'Reopen Hermes to finish.'
} else {
line.stringValue = 'Run hermes debug share in a terminal to send a report.'
}
const glyph = wrappedLabel(settled === 'error' ? '✕' : '✓',
$.NSFont.systemFontOfSize(44), fg, $.NSMakeRect(0, 226, 280, 80))
content.addSubview(glyph)
if (settled === 'done') {
// The app is coming back; the shim's job is over.
linger(1.5)
} else {
// manual/error: mirror stop_ui leave-window so the message is readable
// even without it (the durable result dialog covers the rest).
linger(15)
}
}
function linger (seconds) {
const runloop = $.NSRunLoop.currentRunLoop
const end = $.CACurrentMediaTime() + seconds
while ($.CACurrentMediaTime() < end) {
runloop.runModeBeforeDate(
$.NSDefaultRunLoopMode,
$.NSDate.dateWithTimeIntervalSinceNow(0.05)
)
}
}