File size: 10,391 Bytes
2b2ba56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
<!doctype html>
<!--
  Quiet shim page for the desktop update hand-off (Windows + POSIX).

  Served over loopback by the orchestrator (windows.ps1 / posix.sh) into a
  chromeless browser app window. Pure veneer: polls /progress for the current
  hand-off stage or a terminal event and reacts; owns nothing (relaunch,
  result file, marker hygiene all live in the orchestrator, which runs
  identically with no UI at all).

  The visual is PR #75895's update hand-off screen, ported verbatim:
  - Loader: the desktop's "Fourier Flow" curve. Math + tuning lifted from
    apps/bootstrap-installer/src/components/loader.tsx (itself lifted from
    apps/desktop/src/components/ui/loader.tsx 'fourier-flow'). Keep the
    constants in sync if the desktop's curve is retuned.
  - Layout: loader (size-20) + one title + one muted stage/elapsed line. No
    progress bar, stage list, log pane, or cancel (see #75895 for the
    arguments). Elapsed comes from the orchestrator or is omitted -- a clock
    started here would measure when this window painted, not the update.
  - Appearance follows the OS. Dark seeds are the installer's neutral
    charcoal (#232323 base, foreground #d6d6d6) β€” never brand blue.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hermes</title>
<style>
  :root {
    --background: #ffffff;
    --foreground: #1a1a1a;
    --muted-foreground: #737373;
  }
  @media (prefers-color-scheme: dark) {
    :root {
      --background: #232323;
      --foreground: #d6d6d6;
      --muted-foreground: #8a8a8a;
    }
  }
  html, body {
    margin: 0;
    height: 100%;
    background: var(--background);
    color: var(--foreground);
    font-family: system-ui, 'Segoe UI', sans-serif;
    -webkit-font-smoothing: antialiased;
    user-select: none;
    cursor: default;
    overflow: hidden;
  }
  /* UpdateScreen: flex h-full flex-col items-center justify-center gap-4
     px-6 text-center (routes/progress.tsx) */
  .wrap {
    height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 16px;
    padding: 0 24px;
    box-sizing: border-box;
    text-align: center;
  }
  @keyframes hermes-fade-in {
    from { opacity: 0; transform: translateY(4px); }
    to { opacity: 1; transform: translateY(0); }
  }
  .wrap { animation: hermes-fade-in 0.45s ease-out both; }
  /* Loader size-20 (5rem); svg overflow-visible; curve path opacity .1 */
  #loader { width: 80px; height: 80px; color: var(--foreground); }
  #loader svg { width: 100%; height: 100%; overflow: visible; }
  #glyph {
    width: 80px;
    height: 80px;
    display: none;
    align-items: center;
    justify-content: center;
    font-size: 44px;
    line-height: 1;
  }
  /* text-lg font-semibold tracking-tight */
  h2 {
    margin: 0;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: -0.025em;
  }
  /* text-xs text-muted-foreground */
  p {
    margin: 0;
    max-width: 80%;
    font-size: 12px;
    line-height: 1.5;
    color: var(--muted-foreground);
    white-space: pre-line;
  }
  p code {
    font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
    font-size: 11px;
    color: var(--foreground);
  }
  body.done #loader, body.error #loader, body.disconnected #loader { display: none; }
  body.done #glyph, body.error #glyph, body.disconnected #glyph { display: flex; }
</style>
</head>
<body>
  <div class="wrap">
    <div id="loader" role="status" aria-label="Updating"></div>
    <div id="glyph"></div>
    <h2 id="title">Updating Hermes</h2>
    <p id="line">Hermes will open once done.</p>
  </div>
<script>
  /* ── Fourier Flow loader, ported verbatim from loader.tsx ─────────────── */
  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 buildPath(detailScale, steps) {
    return Array.from({ length: steps + 1 }, (_, i) => {
      const { x, y } = CURVE.point(i / steps, detailScale)

      return `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`
    }).join(' ')
  }

  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 }
  }

  const SVG_NS = 'http://www.w3.org/2000/svg'
  const svg = document.createElementNS(SVG_NS, 'svg')
  svg.setAttribute('viewBox', '0 0 100 100')
  svg.setAttribute('fill', 'none')
  svg.setAttribute('aria-hidden', 'true')
  const curvePath = document.createElementNS(SVG_NS, 'path')
  curvePath.setAttribute('opacity', '0.1')
  curvePath.setAttribute('stroke', 'currentColor')
  curvePath.setAttribute('stroke-linecap', 'round')
  curvePath.setAttribute('stroke-linejoin', 'round')
  curvePath.setAttribute('stroke-width', String(CURVE.strokeWidth))
  svg.appendChild(curvePath)
  const particles = Array.from({ length: CURVE.particleCount }, () => {
    const c = document.createElementNS(SVG_NS, 'circle')
    c.setAttribute('fill', 'currentColor')
    svg.appendChild(c)

    return c
  })
  document.getElementById('loader').appendChild(svg)

  let frame = 0
  const startedAt = performance.now()
  const phaseOffset = Math.random()

  function render(now) {
    const time = now - startedAt
    const progress = ((time + phaseOffset * CURVE.durationMs) % CURVE.durationMs) / CURVE.durationMs
    const detailScale = detailScaleFor(time, phaseOffset)

    curvePath.setAttribute('d', buildPath(detailScale, PATH_STEPS))
    particles.forEach((node, index) => {
      const p = particleFor(index, progress, detailScale)
      node.setAttribute('cx', p.x.toFixed(2))
      node.setAttribute('cy', p.y.toFixed(2))
      node.setAttribute('r', p.radius.toFixed(2))
      node.setAttribute('opacity', p.opacity.toFixed(3))
    })

    frame = window.requestAnimationFrame(render)
  }

  render(performance.now())

  /* ── Event listener: running stage or terminal outcome ───────────────── */
  const titleEl = document.getElementById('title')
  const lineEl = document.getElementById('line')
  const glyphEl = document.getElementById('glyph')
  const defaultLine = lineEl.textContent   /* what a stage-less run says */
  let settled = false
  let failures = 0

  const elapsedText = s =>
    s < 60 ? `${s}s elapsed` : `${Math.floor(s / 60)}m ${s % 60}s elapsed`

  function settle(state) {
    settled = true
    window.cancelAnimationFrame(frame)
    document.body.className = state
  }

  function apply(state) {
    if (settled) return
    if (state.status === 'running') {
      const stage = state.message || defaultLine
      const elapsed = Number(state.elapsed_seconds)
      lineEl.textContent = Number.isFinite(elapsed) && elapsed >= 0
        ? `${stage}\n${elapsedText(Math.floor(elapsed))}`
        : stage
    } else if (state.status === 'done') {
      settle('done')
      glyphEl.textContent = '\u2713'
      titleEl.textContent = 'Update complete'
      lineEl.textContent = 'Opening Hermes\u2026\nYou can close this window.'
    } else if (state.status === 'manual') {
      // Update landed but Hermes will NOT reopen itself (package skew,
      // sandbox helper, launch rejected). The orchestrator leaves this
      // window up; the message says what to do.
      settle('done')
      glyphEl.textContent = '\u2713'
      titleEl.textContent = 'Update complete'
      lineEl.textContent = state.message || 'Reopen Hermes to finish.'
    } else if (state.status === 'error') {
      settle('error')
      glyphEl.textContent = '\u2715'
      titleEl.textContent = 'Failed to update'
      lineEl.innerHTML = 'Run <code>hermes debug share</code> in a terminal to send a report.'
    }
  }

  async function request(url, options = {}) {
    const controller = new AbortController()
    const timeout = setTimeout(() => controller.abort(), 5000)
    try {
      const response = await fetch(url, { ...options, cache: 'no-store', signal: controller.signal })
      if (!response.ok) throw new Error('Progress request failed')
      // Keep the deadline active while reading the body too: receiving
      // headers alone does not prove that the progress server is responsive.
      return options.method === 'POST' ? null : await response.json()
    } finally {
      clearTimeout(timeout)
    }
  }

  async function poll() {
    try {
      const state = await request('/progress')
      if (!state || !['running', 'done', 'manual', 'error'].includes(state.status)) {
        throw new Error('Invalid progress response')
      }
      failures = 0
      apply(state)
      // The Windows server can now wait for delivery instead of guessing
      // that one polling interval was enough. Older/POSIX servers omit this
      // receipt. Apply first so a failed acknowledgement cannot hide a result.
      if (settled && typeof state.receipt === 'string' && state.receipt) {
        await request(`/ack/${encodeURIComponent(state.receipt)}`, { method: 'POST' })
      }
    } catch {
      if (!settled && ++failures >= 3) {
        // A vanished server is not evidence of success OR failure. Leave an
        // honest, finite state even if the browser refuses the close request.
        settle('disconnected')
        glyphEl.textContent = '!'
        titleEl.textContent = 'Update status unavailable'
        lineEl.textContent = 'The progress connection was lost.\nCheck Hermes for the update result. You can close this window.'
      }
    }
    if (!settled) setTimeout(poll, 400)
  }
  poll()
</script>
</body>
</html>