pinokiod 8.2.10 → 8.2.12

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/server/index.js CHANGED
@@ -34,6 +34,7 @@ const Git = require("../kernel/git")
34
34
  const TerminalApi = require('../kernel/api/terminal')
35
35
  const PluginSources = require("../kernel/plugin_sources")
36
36
  const ManagedSkills = require("../kernel/managed_skills")
37
+ const { rewriteHref: rewriteAnywhereHref } = require("../kernel/anywhere/serve")
37
38
  const { isPathRemovalBlockedError, removePath } = require("../kernel/path_removal")
38
39
 
39
40
  const git = require('isomorphic-git')
@@ -1024,8 +1025,6 @@ class Server {
1024
1025
  try {
1025
1026
  const parsed = new URL(value)
1026
1027
  if (parsed.port) return Number(parsed.port)
1027
- if (parsed.protocol === "https:") return 443
1028
- if (parsed.protocol === "http:") return 80
1029
1028
  } catch (_) { }
1030
1029
  return null
1031
1030
  }
@@ -1046,17 +1045,19 @@ class Server {
1046
1045
  let entries = []
1047
1046
  try {
1048
1047
  entries = await this.kernel.api.listApps()
1049
- } catch (_) { }
1048
+ } catch (error) {
1049
+ return Object.assign(snap, { phase: "error", error: `Could not list apps: ${error.message}` })
1050
+ }
1050
1051
  snap.installed = entries.length
1051
- snap.apps = []
1052
1052
  if (snap.phase === "ready") {
1053
1053
  const serveByLocalPort = new Map((snap.serve || []).map((entry) => [entry.local, entry]))
1054
1054
  let running = []
1055
1055
  try {
1056
- running = await this.collectHomeServerApps(entries)
1057
- } catch (_) {
1058
- running = []
1056
+ running = await this.collectHomeServerApps(entries, true)
1057
+ } catch (error) {
1058
+ return Object.assign(snap, { phase: "error", error: `Could not list running apps: ${error.message}` })
1059
1059
  }
1060
+ snap.apps = []
1060
1061
  for (const app of running) {
1061
1062
  const port = this.portFromUrl(app.ready_url || app.url)
1062
1063
  const entry = port ? serveByLocalPort.get(port) : null
@@ -1064,8 +1065,8 @@ class Server {
1064
1065
  id: app.id,
1065
1066
  name: app.name,
1066
1067
  icon: app.icon,
1067
- state: app.state,
1068
- remote: entry ? entry.url : null,
1068
+ state: entry ? app.state : (app.state === "ready" ? "publishing" : app.state),
1069
+ remote: entry ? rewriteAnywhereHref(app.ready_url || app.url, snap.serve) : null,
1069
1070
  exposed: !!entry,
1070
1071
  })
1071
1072
  }
@@ -1083,7 +1084,7 @@ class Server {
1083
1084
  return snap
1084
1085
  }
1085
1086
  // `entries`: an app list from kernel.api.listApps() the caller already has
1086
- async collectHomeServerApps(entries = null) {
1087
+ async collectHomeServerApps(entries = null, strict = false) {
1087
1088
  const apps = []
1088
1089
  if (!this.kernel || !this.kernel.api || typeof this.kernel.api.listApps !== "function") {
1089
1090
  return apps
@@ -1106,7 +1107,8 @@ class Server {
1106
1107
  runtime = this.appRegistry && typeof this.appRegistry.collectAppRuntime === "function"
1107
1108
  ? this.appRegistry.collectAppRuntime(appRoot)
1108
1109
  : null
1109
- } catch (_) {
1110
+ } catch (error) {
1111
+ if (strict) throw error
1110
1112
  runtime = null
1111
1113
  }
1112
1114
  if (!runtime || !runtime.running) {
@@ -14241,7 +14243,7 @@ class Server {
14241
14243
  })
14242
14244
  }))
14243
14245
  this.app.get("/anywhere", ex(async (req, res) => {
14244
- let { requirements, install_required, requirements_pending, error } = await this.kernel.bin.check({
14246
+ let { install_required, requirements_pending } = await this.kernel.bin.check({
14245
14247
  bin: this.kernel.bin.preset("anywhere"),
14246
14248
  })
14247
14249
  if (!requirements_pending && install_required) {
@@ -14259,12 +14261,6 @@ class Server {
14259
14261
  const status = await this.composeAnywhereStatus()
14260
14262
  res.render("anywhere", {
14261
14263
  status,
14262
- requirements_pending,
14263
- install_required,
14264
- error,
14265
- docs: this.docs,
14266
- portal: this.portal,
14267
- logo: this.logo,
14268
14264
  theme: this.theme,
14269
14265
  agent: req.agent,
14270
14266
  })
@@ -14287,7 +14283,7 @@ class Server {
14287
14283
  return
14288
14284
  }
14289
14285
  try {
14290
- await fn(anywhere, req)
14286
+ await fn(anywhere)
14291
14287
  res.json({ success: true, status: await this.composeAnywhereStatus() })
14292
14288
  } catch (error) {
14293
14289
  console.log(`[anywhere] ${label} failed`, error)
@@ -4429,6 +4429,11 @@
4429
4429
  ui.intentWrap.setAttribute('aria-hidden', hasPresetIntent ? 'true' : 'false');
4430
4430
  }
4431
4431
  ui.setIntent(type);
4432
+ // setIntent saved the cleared textarea over the draft when the intent was
4433
+ // already selected (the default), dropping a prompt passed in defaults.
4434
+ // Restore it as if typed, so the draft, folder name and height follow.
4435
+ ui.promptTextarea.value = buildInitialPrompt(type, prompt);
4436
+ ui.promptTextarea.dispatchEvent(new Event('input'));
4432
4437
  ui.setMode(mode);
4433
4438
  if (name) {
4434
4439
  ui.nameInput.value = name;
@@ -136,7 +136,7 @@ document.addEventListener('DOMContentLoaded', function() {
136
136
  <p class='task-description'>Open this Pinokio and its running apps from your phone or another computer, anywhere, over a private encrypted network.</p>
137
137
  </div>
138
138
  <div class='task-header-actions'>
139
- <div class='anywhere-switch' id='anywhere-switch' data-on='false' data-busy='false'>
139
+ <div class='anywhere-switch' id='anywhere-switch' data-on='false'>
140
140
  <i class="fa-solid fa-power-off"></i> <span id='anywhere-switch-label'>Off</span>
141
141
  </div>
142
142
  </div>
@@ -190,7 +190,7 @@ document.addEventListener('DOMContentLoaded', function() {
190
190
  <div class='anywhere-card'>
191
191
  <h2>Something went wrong</h2>
192
192
  <div class='anywhere-error' id='anywhere-error'></div>
193
- <p><button class='anywhere-btn' data-action='start'><i class="fa-solid fa-rotate-right"></i> Try again</button></p>
193
+ <p><button class='anywhere-btn' id='anywhere-retry' data-action='start'><i class="fa-solid fa-rotate-right"></i> Try again</button></p>
194
194
  </div>
195
195
  </div>
196
196
 
@@ -255,7 +255,6 @@ document.addEventListener('DOMContentLoaded', function() {
255
255
  const agent = document.body.getAttribute('data-agent')
256
256
  let current = <%- JSON.stringify(status || {}).replace(/</g, '\\u003c') %>
257
257
  let busy = false
258
- let timer = null
259
258
 
260
259
  const esc = (value) => String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
261
260
  const openExternal = (href) => {
@@ -315,6 +314,7 @@ document.addEventListener('DOMContentLoaded', function() {
315
314
  }
316
315
  if (phase === 'error') {
317
316
  document.getElementById('anywhere-error').textContent = current.error || current.serveError || 'unknown error'
317
+ document.getElementById('anywhere-retry').dataset.action = current.enabled === false ? 'stop' : 'start'
318
318
  }
319
319
  if (phase === 'ready') {
320
320
  const qr = document.getElementById('anywhere-qr')
@@ -368,32 +368,32 @@ document.addEventListener('DOMContentLoaded', function() {
368
368
  const poll = async () => {
369
369
  try {
370
370
  const response = await fetch('/anywhere/status', { cache: 'no-store' })
371
- if (response.ok) {
372
- render(await response.json())
373
- }
374
- } catch (e) { }
371
+ if (!response.ok) throw new Error(`Could not refresh Anywhere status (${response.status})`)
372
+ render(await response.json())
373
+ } catch (e) {
374
+ render(Object.assign({}, current, { phase: 'error', error: e.message }))
375
+ }
375
376
  const phase = current.phase || ''
376
377
  const delay = (phase === 'ready' || phase === 'off') ? 5000 : 2000
377
- timer = setTimeout(poll, delay)
378
+ setTimeout(poll, delay)
378
379
  }
379
380
 
380
381
  const act = async (action) => {
381
382
  if (busy) return
382
383
  busy = true
383
- switchEl.dataset.busy = 'true'
384
+ const enabled = action === 'stop' ? false : action === 'start' ? true : current.enabled
384
385
  try {
385
386
  const response = await fetch(`/anywhere/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
386
387
  const data = await response.json()
387
388
  if (data && data.status) {
388
389
  render(data.status)
389
390
  } else if (data && data.error) {
390
- render(Object.assign({}, current, { phase: 'error', error: data.error }))
391
+ render(Object.assign({}, current, { phase: 'error', error: data.error, enabled }))
391
392
  }
392
393
  } catch (e) {
393
- render(Object.assign({}, current, { phase: 'error', error: e.message }))
394
+ render(Object.assign({}, current, { phase: 'error', error: e.message, enabled }))
394
395
  } finally {
395
396
  busy = false
396
- switchEl.dataset.busy = 'false'
397
397
  }
398
398
  }
399
399
 
@@ -429,7 +429,7 @@ document.addEventListener('DOMContentLoaded', function() {
429
429
  })
430
430
 
431
431
  render(current)
432
- timer = setTimeout(poll, 1500)
432
+ setTimeout(poll, 1500)
433
433
  })()
434
434
  </script>
435
435
  </body>
@@ -140,7 +140,6 @@
140
140
  badge.textContent = label;
141
141
  badge.hidden = false;
142
142
  badge.dataset.state = on ? "on" : "off";
143
- tab.dataset.anywhereStatus = phase;
144
143
  tab.setAttribute("title", `Pinokio Anywhere ${label}`);
145
144
  };
146
145
  document.addEventListener("pinokio:anywhere-status", (event) => {
@@ -50,7 +50,7 @@ h2 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacin
50
50
  <h2>Running now</h2>
51
51
  <div id="cards"></div>
52
52
  </section>
53
- <div id="notice" class="notice" hidden><strong>Nothing is running</strong>Start an app on your computer and it shows up here.</div>
53
+ <div id="notice" class="notice" hidden></div>
54
54
  <a id="door" class="door" href="/home">
55
55
  <i class="fa-solid fa-plus"></i>
56
56
  <div class="body"><div class="title" id="door-title"></div><div class="cap" id="door-cap"></div></div>
@@ -66,8 +66,13 @@ h2 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacin
66
66
  const render = (status) => {
67
67
  current = status || {}
68
68
  const apps = Array.isArray(current.apps) ? current.apps : []
69
+ const available = current.phase === 'ready' && Array.isArray(current.apps)
69
70
  document.getElementById('running').hidden = apps.length === 0
70
71
  document.getElementById('notice').hidden = apps.length > 0
72
+ const states = { setup: 'Set up Pinokio Anywhere', off: 'Pinokio Anywhere is off', starting: 'Starting Pinokio Anywhere…', login: 'Sign in to Tailscale', machine_auth: 'Device approval required', https: 'HTTPS approval required' }
73
+ document.getElementById('notice').innerHTML = available
74
+ ? '<strong>Nothing is running</strong>Start an app on your computer and it shows up here.'
75
+ : `<strong>${esc(current.error || current.serveError || states[current.phase] || 'Pinokio Anywhere is unavailable')}</strong><a href="/anywhere">Open Anywhere</a>`
71
76
  document.getElementById('cards').innerHTML = apps.map((app) => `
72
77
  <div class="card">
73
78
  <img src="${esc(app.icon || '/pinokio-black.png')}" alt="">
@@ -80,13 +85,16 @@ h2 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacin
80
85
  const door = document.getElementById('door')
81
86
  door.className = 'door ' + (apps.length > 0 ? 'dashed' : 'solid')
82
87
  document.getElementById('door-title').textContent = apps.length > 0 ? 'Start another app' : 'Start an app'
83
- document.getElementById('door-cap').textContent = `${current.installed || 0} installed apps in Pinokio`
88
+ document.getElementById('door-cap').textContent = typeof current.installed === 'number' ? `${current.installed} installed apps in Pinokio` : ''
84
89
  }
85
90
  const poll = async () => {
86
91
  try {
87
92
  const response = await fetch('/anywhere/status', { cache: 'no-store' })
88
- if (response.ok) render(await response.json())
89
- } catch (_) { }
93
+ if (!response.ok) throw new Error(`Could not refresh app status (${response.status})`)
94
+ render(await response.json())
95
+ } catch (error) {
96
+ render({ ...current, phase: 'error', error: error.message, apps: undefined })
97
+ }
90
98
  setTimeout(poll, 5000)
91
99
  }
92
100
  render(current)
@@ -0,0 +1,6 @@
1
+ {
2
+ "version": "26.2.2",
3
+ "filename": "pip-26.2.2-py3-none-any.whl",
4
+ "url": "https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl",
5
+ "sha256": "fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70"
6
+ }
@@ -0,0 +1,100 @@
1
+ """Prepare a managed interpreter's local pip seed without changing its venvs."""
2
+
3
+ import sys
4
+
5
+
6
+ def repair(wheel_path, prefix, check=False):
7
+ if sys.version_info < (3, 10):
8
+ if check:
9
+ raise RuntimeError("pip seed mitigation requires Python 3.10+")
10
+ return
11
+
12
+ import ensurepip
13
+ from email.parser import BytesParser
14
+ import hashlib
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+ import re
19
+ import stat
20
+ import tempfile
21
+ import zipfile
22
+
23
+ def resolved(value):
24
+ return os.path.normcase(os.path.realpath(value))
25
+
26
+ prefix = resolved(prefix)
27
+ source = Path(ensurepip.__file__).resolve()
28
+ if (resolved(sys.prefix) != prefix or resolved(sys.base_prefix) != prefix
29
+ or os.path.commonpath([prefix, resolved(source)]) != prefix):
30
+ raise RuntimeError("Refusing pip seed repair outside the selected Python prefix")
31
+ if getattr(ensurepip, "_WHEEL_PKG_DIR", None):
32
+ raise RuntimeError("Unsupported external ensurepip wheel directory")
33
+
34
+ pin = json.loads(Path(__file__).with_name("pip_seed.json").read_text())
35
+ content = source.read_bytes()
36
+ match = re.search(br'^_PIP_VERSION = ["\']([^"\']+)["\']', content, re.M)
37
+ if not match or not re.fullmatch(br"\d+(?:\.\d+)*", match[1]):
38
+ raise RuntimeError("Unrecognized ensurepip version reference")
39
+ version = match[1].decode("ascii")
40
+ release = lambda value: tuple(map(int, value.split(".")))
41
+
42
+ def verified(path):
43
+ return hashlib.sha256(path.read_bytes()).hexdigest() == pin["sha256"]
44
+
45
+ wheel_data = Path(wheel_path).read_bytes()
46
+ if hashlib.sha256(wheel_data).hexdigest() != pin["sha256"]:
47
+ raise RuntimeError("Cached pip wheel SHA-256 verification failed")
48
+ bundled = source.parent / "_bundled"
49
+ if os.path.commonpath([prefix, resolved(bundled)]) != prefix:
50
+ raise RuntimeError("Refusing pip seed repair outside the selected Python prefix")
51
+ destination = bundled / pin["filename"]
52
+
53
+ def clear_bytecode():
54
+ for cache in (source.parent / "__pycache__").glob("__init__.*.pyc"):
55
+ cache.unlink(missing_ok=True)
56
+
57
+ if release(version) > release(pin["version"]):
58
+ newer = bundled / ("pip-" + version + "-py3-none-any.whl")
59
+ try:
60
+ with zipfile.ZipFile(newer) as archive:
61
+ metadata = BytesParser().parsebytes(archive.read("pip-" + version + ".dist-info/METADATA"))
62
+ valid = (metadata["Name"] == "pip" and metadata["Version"] == version
63
+ and "pip/__init__.py" in archive.namelist() and archive.testzip() is None)
64
+ except (OSError, KeyError, zipfile.BadZipFile):
65
+ valid = False
66
+ if valid:
67
+ if ensurepip.version() != version:
68
+ if check:
69
+ raise RuntimeError("Managed Python ensurepip bytecode needs repair")
70
+ clear_bytecode()
71
+ return
72
+ if check:
73
+ if version != pin["version"] or ensurepip.version() != version or not verified(destination):
74
+ raise RuntimeError("Managed Python pip seed needs repair")
75
+ return
76
+
77
+ def publish(path, data, mode):
78
+ descriptor, temporary = tempfile.mkstemp(dir=str(path.parent))
79
+ try:
80
+ with os.fdopen(descriptor, "wb") as handle:
81
+ handle.write(data)
82
+ os.chmod(temporary, mode)
83
+ os.replace(temporary, path)
84
+ finally:
85
+ if os.path.exists(temporary):
86
+ os.unlink(temporary)
87
+
88
+ # Publish the wheel first and retain the old one if source replacement fails.
89
+ if not destination.is_file() or not verified(destination):
90
+ mode = stat.S_IMODE(destination.stat().st_mode) if destination.exists() else 0o644
91
+ publish(destination, wheel_data, mode)
92
+ updated = content[:match.start(1)] + pin["version"].encode("ascii") + content[match.end(1):]
93
+ if updated != content:
94
+ publish(source, updated, stat.S_IMODE(source.stat().st_mode))
95
+ if updated != content or ensurepip.version() != pin["version"]:
96
+ clear_bytecode()
97
+
98
+
99
+ if __name__ == "__main__":
100
+ repair(sys.argv[1], sys.argv[2], "--check" in sys.argv[3:])
@@ -3,32 +3,137 @@ const path = require('node:path')
3
3
  const test = require('node:test')
4
4
  const ejs = require('ejs')
5
5
  const Server = require('../server/index.js')
6
+ const Anywhere = require('../kernel/anywhere')
7
+ const { buildServeConfig, describeServeConfig } = require('../kernel/anywhere/serve')
8
+ const { JSDOM } = require('jsdom')
6
9
 
7
10
  const view = path.resolve(__dirname, '..', 'server', 'views', 'remote.ejs')
8
11
 
9
12
  // composeAnywhereStatus with its collaborators stubbed: the launcher needs the
10
13
  // installed count and each running app's id next to its remote address.
11
- test('anywhere status carries the installed count and each app id', async () => {
14
+ test('anywhere status carries the installed count, app ids and complete app URLs', async () => {
15
+ const anywhere = new Anywhere({})
16
+ anywhere.config = { enabled: true }
17
+ anywhere.lastStatus = { Self: { DNSName: 'pinokio.tail1.ts.net.' } }
18
+ anywhere.lastServe = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', targets: [{ listen: 43000, target: 7860 }] })
19
+ anywhere.snapshot = async () => ({ phase: 'ready', dnsName: 'pinokio.tail1.ts.net', serve: describeServeConfig(anywhere.lastServe) })
20
+ let readyUrl
12
21
  const fake = {
13
22
  kernel: {
14
- anywhere: {
15
- snapshot: async () => ({ phase: 'ready', connectUrl: null, serve: [{ local: 7860, url: 'https://pinokio.tail1.ts.net:43000' }] }),
16
- },
23
+ anywhere,
17
24
  api: { listApps: async () => [{ id: 'comfy' }, { id: 'forge' }, { id: 'idle' }] },
18
25
  },
19
26
  collectHomeServerApps: async () => [
20
- { id: 'comfy', name: 'ComfyUI', icon: '/c.png', ready_url: 'http://127.0.0.1:7860', state: 'ready' },
27
+ { id: 'comfy', name: 'ComfyUI', icon: '/c.png', ready_url: readyUrl, state: 'ready' },
21
28
  { id: 'forge', name: 'Forge', icon: '/f.png', ready_url: '', state: 'starting' },
22
29
  ],
23
30
  portFromUrl: Server.prototype.portFromUrl,
24
31
  }
25
- const status = await Server.prototype.composeAnywhereStatus.call(fake, {})
26
- assert.equal(status.installed, 3)
27
- assert.deepEqual(status.apps.map((a) => [a.id, a.remote]), [['comfy', 'https://pinokio.tail1.ts.net:43000'], ['forge', null]])
32
+ for (const [url, expected] of [
33
+ ['http://127.0.0.1:7860/ui/?token=keep%20me#view', 'https://pinokio.tail1.ts.net:43000/ui/?token=keep%20me#view'],
34
+ ['http://127.0.0.1:7860', 'https://pinokio.tail1.ts.net:43000/'],
35
+ ]) {
36
+ readyUrl = url
37
+ const status = await Server.prototype.composeAnywhereStatus.call(fake, {})
38
+ assert.equal(status.installed, 3)
39
+ assert.deepEqual(status.apps.map((a) => [a.id, a.remote]), [['comfy', expected], ['forge', null]])
40
+ }
28
41
  const light = await Server.prototype.composeAnywhereStatus.call(fake, { light: true })
29
42
  assert.equal(light.installed, undefined, 'the light status is unchanged')
30
43
  })
31
44
 
45
+ test('remote links use captured routes when Anywhere stops during app collection', async () => {
46
+ for (const stage of ['list', 'runtime']) {
47
+ const anywhere = new Anywhere({})
48
+ anywhere.config = { enabled: true }
49
+ anywhere.lastStatus = { Self: { DNSName: 'pinokio.tail1.ts.net.' } }
50
+ anywhere.lastServe = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', targets: [{ listen: 43000, target: 7860 }] })
51
+ anywhere.snapshot = async () => ({ phase: 'ready', dnsName: 'pinokio.tail1.ts.net', serve: describeServeConfig(anywhere.lastServe) })
52
+ const stop = () => { anywhere.config.enabled = false; anywhere.lastServe = undefined; anywhere.lastStatus = null }
53
+ const fake = {
54
+ kernel: { anywhere, api: { listApps: async () => { if (stage === 'list') stop(); return [{ id: 'app' }] } } },
55
+ collectHomeServerApps: async () => {
56
+ if (stage === 'runtime') stop()
57
+ return [{ id: 'app', state: 'ready', ready_url: 'http://127.0.0.1:7860/ui/?token=keep%20me#view' }]
58
+ },
59
+ portFromUrl: Server.prototype.portFromUrl,
60
+ }
61
+ const status = await Server.prototype.composeAnywhereStatus.call(fake)
62
+ assert.equal(status.apps[0].remote, 'https://pinokio.tail1.ts.net:43000/ui/?token=keep%20me#view', stage)
63
+ assert.equal(status.apps[0].exposed, true)
64
+ }
65
+ })
66
+
67
+ test('unavailable app lists remain distinct from a confirmed empty list', async () => {
68
+ for (const phase of ['setup', 'off', 'starting', 'login', 'machine_auth', 'https', 'error']) {
69
+ const status = await Server.prototype.composeAnywhereStatus.call({
70
+ kernel: { anywhere: { snapshot: async () => ({ phase }) }, api: { listApps: async () => [] } },
71
+ })
72
+ assert.equal(status.apps, undefined, phase)
73
+ }
74
+ const failed = await Server.prototype.composeAnywhereStatus.call({
75
+ kernel: { anywhere: { snapshot: async () => ({ phase: 'ready' }) }, api: { listApps: async () => { throw new Error('unavailable') } } },
76
+ })
77
+ assert.equal(failed.phase, 'error')
78
+ assert.match(failed.error, /unavailable/)
79
+ assert.equal(failed.apps, undefined)
80
+ })
81
+
82
+ test('Anywhere reports runtime inspection failures while Home Server keeps its existing behavior', async () => {
83
+ const fake = {
84
+ kernel: {
85
+ path: (...parts) => path.join('/managed', ...parts),
86
+ anywhere: { snapshot: async () => ({ phase: 'ready', enabled: true, serve: [] }) },
87
+ api: { listApps: async () => [{ id: 'comfy', name: 'ComfyUI' }] },
88
+ },
89
+ appRegistry: { collectAppRuntime() { throw new Error('runtime inspection failed') } },
90
+ collectHomeServerApps: Server.prototype.collectHomeServerApps,
91
+ portFromUrl: Server.prototype.portFromUrl,
92
+ }
93
+ const status = await Server.prototype.composeAnywhereStatus.call(fake)
94
+ assert.equal(status.phase, 'error')
95
+ assert.match(status.error, /runtime inspection failed/)
96
+ assert.equal(status.apps, undefined)
97
+ const html = await ejs.renderFile(view, { status, theme: 'light', locals: {} })
98
+ const dom = new JSDOM(html, { runScripts: 'outside-only', url: 'http://localhost:42000' })
99
+ try {
100
+ dom.window.setTimeout = () => 0
101
+ dom.window.eval(dom.window.document.querySelector('script').textContent)
102
+ assert.match(dom.window.document.getElementById('notice').textContent, /runtime inspection failed/)
103
+ assert.doesNotMatch(dom.window.document.getElementById('notice').textContent, /Nothing is running/)
104
+ } finally { dom.window.close() }
105
+ assert.deepEqual(await fake.collectHomeServerApps(), [])
106
+ })
107
+
108
+ test('launcher renders actual states, top-level links and polling failures', async () => {
109
+ for (const status of [
110
+ { phase: 'off' }, { phase: 'setup' }, { phase: 'error', error: '<broken>' },
111
+ { phase: 'ready', installed: 0, apps: [] },
112
+ { phase: 'ready', installed: 2, apps: [
113
+ { id: 'comfy', name: 'Comfy', remote: 'https://pinokio.tail1.ts.net:43000' },
114
+ { id: 'forge', name: 'Forge', state: 'starting', remote: null },
115
+ ] },
116
+ ]) {
117
+ const html = await ejs.renderFile(view, { status, theme: 'light', locals: {} })
118
+ const dom = new JSDOM(html, { runScripts: 'outside-only', url: 'http://localhost:42000' })
119
+ try {
120
+ const timers = []
121
+ dom.window.setTimeout = (fn) => timers.push(fn)
122
+ dom.window.fetch = async () => ({ ok: false, status: 503 })
123
+ dom.window.eval(dom.window.document.querySelector('script').textContent)
124
+ const doc = dom.window.document
125
+ assert.equal(doc.querySelector('base').target, '_top')
126
+ assert.equal(doc.querySelector('#notice').textContent.includes('Nothing is running'), status.phase === 'ready')
127
+ assert.equal(doc.querySelectorAll('a.open').length, status.apps && status.apps.length ? 1 : 0)
128
+ if (status.error) assert.ok(doc.querySelector('#notice').textContent.includes('<broken>'))
129
+ await timers.shift()()
130
+ assert.equal(doc.querySelectorAll('a.open').length, 0)
131
+ assert.match(doc.querySelector('#notice').textContent, /503/)
132
+ assert.doesNotMatch(doc.querySelector('#notice').textContent, /Nothing is running/)
133
+ } finally { dom.window.close() }
134
+ }
135
+ })
136
+
32
137
  test('the launcher page renders the running apps and one door to the full interface', async () => {
33
138
  const render = (status) => ejs.renderFile(view, { status, theme: 'light', locals: {} })
34
139
 
@@ -46,3 +151,72 @@ test('the launcher page renders the running apps and one door to the full interf
46
151
  assert.match(some, /"id":"forge"/)
47
152
  assert.doesNotMatch(some, /<link rel="manifest"/, 'manifest tags only for the Tailscale host')
48
153
  })
154
+
155
+ test('Anywhere shows polling failures and recovers on the next successful response', async () => {
156
+ for (const failure of ['http', 'network']) {
157
+ const status = { phase: 'ready', enabled: true }
158
+ const html = await ejs.renderFile(path.resolve(__dirname, '..', 'server/views/anywhere.ejs'), {
159
+ status, theme: 'light', agent: 'browser',
160
+ }, { includer: () => ({ template: '' }) })
161
+ const dom = new JSDOM(html, { runScripts: 'outside-only', url: 'http://localhost:42000' })
162
+ try {
163
+ const timers = []
164
+ let requests = 0
165
+ dom.window.setTimeout = fn => timers.push(fn)
166
+ dom.window.fetch = async () => {
167
+ if (++requests === 1) {
168
+ if (failure === 'network') throw new Error('Connection failed')
169
+ return { ok: false, status: 503 }
170
+ }
171
+ return { ok: true, json: async () => status }
172
+ }
173
+ const { document } = dom.window
174
+ dom.window.eval([...document.querySelectorAll('script')].at(-1).textContent)
175
+ await timers.shift()()
176
+ assert.equal(document.getElementById('anywhere-root').dataset.phase, 'error')
177
+ assert.match(document.getElementById('anywhere-error').textContent, failure === 'http' ? /503/ : /Connection failed/)
178
+ await timers.shift()()
179
+ assert.equal(document.getElementById('anywhere-root').dataset.phase, 'ready')
180
+ } finally { dom.window.close() }
181
+ }
182
+ })
183
+
184
+ test('Anywhere retries the requested start or stop after API and network failures', async () => {
185
+ for (const action of ['start', 'stop']) {
186
+ for (const failure of ['saved', 'api', 'network']) {
187
+ const enabled = action === 'start'
188
+ const status = failure === 'saved'
189
+ ? { phase: 'error', enabled, error: 'Previous failure' }
190
+ : { phase: enabled ? 'off' : 'ready', enabled: !enabled }
191
+ const html = await ejs.renderFile(path.resolve(__dirname, '..', 'server/views/anywhere.ejs'), {
192
+ status, theme: 'light', agent: 'browser',
193
+ }, { includer: () => ({ template: '' }) })
194
+ const dom = new JSDOM(html, { runScripts: 'outside-only', url: 'http://localhost:42000' })
195
+ try {
196
+ const requests = []
197
+ const { document } = dom.window
198
+ dom.window.setTimeout = () => 0
199
+ dom.window.fetch = async (url, options) => {
200
+ requests.push(url)
201
+ assert.equal(options.method, 'POST')
202
+ if (requests.length === 1 && failure !== 'saved') {
203
+ if (failure === 'network') throw new Error('Connection failed')
204
+ return { json: async () => ({ error: 'Operation failed' }) }
205
+ }
206
+ return { json: async () => ({ status: { phase: enabled ? 'starting' : 'off', enabled } }) }
207
+ }
208
+ dom.window.eval([...document.querySelectorAll('script')].at(-1).textContent)
209
+ if (failure !== 'saved') {
210
+ document.getElementById('anywhere-switch').click()
211
+ await new Promise(setImmediate)
212
+ assert.equal(document.getElementById('anywhere-root').dataset.phase, 'error')
213
+ assert.match(document.getElementById('anywhere-error').textContent, /failed/)
214
+ }
215
+ document.getElementById('anywhere-retry').click()
216
+ await new Promise(setImmediate)
217
+ assert.deepEqual(requests, Array(failure === 'saved' ? 1 : 2).fill(`/anywhere/${action}`))
218
+ assert.equal(document.getElementById('anywhere-root').dataset.phase, enabled ? 'starting' : 'off')
219
+ } finally { dom.window.close() }
220
+ }
221
+ }
222
+ })