pinokiod 8.2.16 → 8.2.18

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.
@@ -1,5 +1,7 @@
1
1
  const path = require("path")
2
2
 
3
+ const isWebUrl = (value) => typeof value === "string" && /^https?:\/\//i.test(value)
4
+
3
5
  const filterSelectors = (preferred) => {
4
6
  if (Array.isArray(preferred)) {
5
7
  return preferred.filter((value) => typeof value === "string" && value.trim().length > 0)
@@ -108,6 +110,26 @@ const loadMenu = async (api, repoPath) => {
108
110
  return Array.isArray(config.menu) ? config.menu : []
109
111
  }
110
112
 
113
+ // every menu item with an href, nested groups flattened in menu order, with its parsed target
114
+ const menuTargets = async (api, repoPath) => {
115
+ let out = []
116
+ let stack = [...(await loadMenu(api, repoPath))]
117
+ while (stack.length > 0) {
118
+ let item = stack.shift()
119
+ if (!item || typeof item !== "object") {
120
+ continue
121
+ }
122
+ if (Array.isArray(item.menu)) {
123
+ stack.unshift(...item.menu)
124
+ }
125
+ let target = menuTarget(item)
126
+ if (target) {
127
+ out.push({ item, target })
128
+ }
129
+ }
130
+ return out
131
+ }
132
+
111
133
  module.exports = async (api, repoPath, preferred = []) => {
112
134
  repoPath = await api.launcher_path(repoPath)
113
135
  let defaultTarget = await api.get_default(repoPath)
@@ -120,24 +142,12 @@ module.exports = async (api, repoPath, preferred = []) => {
120
142
  if (selectors.length === 0) {
121
143
  return undefined
122
144
  }
123
- let stack = [...(await loadMenu(api, repoPath))]
124
- while (stack.length > 0) {
125
- let item = stack.shift()
126
- if (!item || typeof item !== "object") {
127
- continue
128
- }
129
- if (Array.isArray(item.menu)) {
130
- stack.unshift(...item.menu)
131
- }
132
- let target = menuTarget(item)
133
- if (!target) {
134
- continue
135
- }
145
+ for (let { target } of await menuTargets(api, repoPath)) {
136
146
  for (let selector of selectors) {
137
147
  if (!selectorMatches(selector, target)) {
138
148
  continue
139
149
  }
140
- if (target.uri.startsWith("http")) {
150
+ if (isWebUrl(target.uri)) {
141
151
  return {
142
152
  uri: target.href
143
153
  }
@@ -149,3 +159,6 @@ module.exports = async (api, repoPath, preferred = []) => {
149
159
  }
150
160
  }
151
161
  }
162
+
163
+ module.exports.menuTargets = menuTargets
164
+ module.exports.isWebUrl = isWebUrl
@@ -54,7 +54,10 @@ class Brew {
54
54
  await this.kernel.bin.exec({
55
55
  message: "brew install gettext --force-bottle",
56
56
  conda: { skip: true },
57
- env: this.nonInteractiveEnv()
57
+ env: {
58
+ ...this.nonInteractiveEnv(),
59
+ HOMEBREW_NO_RELOCATE_BUILD_PREFIX: "1"
60
+ }
58
61
  }, (stream) => { ondata(stream) })
59
62
  //
60
63
  ondata({ raw: `Install finished\r\n` })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.2.16",
3
+ "version": "8.2.18",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -34,9 +34,13 @@ 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
+ const { rewriteHref: rewriteAnywhereHref, LOOPBACK_HOSTS: ANYWHERE_LOOPBACK_HOSTS } = require("../kernel/anywhere/serve")
38
+ const { menuTargets: launcherMenuTargets, isWebUrl } = require("../kernel/api/launcher_target")
38
39
  const { isPathRemovalBlockedError, removePath } = require("../kernel/path_removal")
39
40
 
41
+ // a menu href whose query says "show, don't run" on the desktop (file view, editor, raw file, view source)
42
+ const isInterfaceLink = (input) => "fs" in input || "command" in input || "raw" in input || input.mode === "source"
43
+
40
44
  const git = require('isomorphic-git')
41
45
  const http = require('isomorphic-git/http/node')
42
46
  const marked = require('marked')
@@ -1028,6 +1032,78 @@ class Server {
1028
1032
  } catch (_) { }
1029
1033
  return null
1030
1034
  }
1035
+ // the installed catalog for the phone dashboard (spec/requirements/pinokio-anywhere-api.md)
1036
+ async composeAnywhereApps() {
1037
+ const entries = await this.kernel.api.listApps()
1038
+ return { apps: entries.map((entry) => ({ id: entry.id, name: entry.title || entry.name || entry.id, icon: entry.icon })) }
1039
+ }
1040
+ // launch is a side effect: only pinokio.co and this computer may call it
1041
+ anywhereOriginAllowed(origin) {
1042
+ if (!origin) return true
1043
+ let parsed
1044
+ try { parsed = new URL(origin) } catch (_) { return false }
1045
+ if (parsed.origin === "https://pinokio.co") return true
1046
+ const host = parsed.hostname.toLowerCase()
1047
+ if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return true
1048
+ const anywhere = this.kernel ? this.kernel.anywhere : null
1049
+ const dnsName = anywhere && typeof anywhere.dnsNameFrom === "function" ? anywhere.dnsNameFrom(anywhere.lastStatus) : null
1050
+ return !!dnsName && parsed.protocol === "https:" && host === dnsName.toLowerCase()
1051
+ }
1052
+ // the app's menu as launch targets: scripts and web addresses, never interface links
1053
+ async anywhereMenu(api, repoPath) {
1054
+ const items = []
1055
+ for (const { item, target } of await launcherMenuTargets(api, repoPath)) {
1056
+ const web = isWebUrl(target.uri)
1057
+ if (!web && !/\.(js|json)$/i.test(target.uri)) continue
1058
+ if (isInterfaceLink(target.input)) continue
1059
+ items.push({
1060
+ text: item.text || "",
1061
+ icon: item.icon || "",
1062
+ href: target.href,
1063
+ default: !!item.default,
1064
+ uri: web ? target.href : path.resolve(repoPath, target.uri),
1065
+ input: !web && Object.keys(target.input).length > 0 ? target.input : undefined,
1066
+ })
1067
+ }
1068
+ return items
1069
+ }
1070
+ async composeAnywhereLaunch({ id, href } = {}) {
1071
+ const api = this.kernel.api
1072
+ const dir = typeof id === "string" ? this.kernel.path("api", id) : ""
1073
+ if (!dir || path.dirname(dir) !== this.kernel.path("api") || path.basename(dir) !== id || !(await api.exists(dir))) {
1074
+ return { status: 400, body: { error: `Unknown app: ${id}` } }
1075
+ }
1076
+ const menu = await this.anywhereMenu(api, await api.launcher_path(id))
1077
+ const target = href ? menu.find((item) => item.href === href) : menu.find((item) => item.default)
1078
+ if (!target) {
1079
+ if (href) return { status: 400, body: { error: `Not in the menu: ${href}` } }
1080
+ return { status: 200, body: { action: "menu", menu: menu.map(({ text, icon, href }) => ({ text, icon, href })) } }
1081
+ }
1082
+ if (isWebUrl(target.uri)) {
1083
+ // a local address becomes its Anywhere address, or null until a Serve route carries it; any other URL passes through
1084
+ let url = target.uri
1085
+ if (ANYWHERE_LOOPBACK_HOSTS.has(new URL(url).hostname)) {
1086
+ const snap = this.kernel.anywhere ? await this.kernel.anywhere.snapshot() : null
1087
+ const rewritten = snap && snap.phase === "ready" ? rewriteAnywhereHref(url, snap.serve || []) : url
1088
+ url = rewritten === url ? null : rewritten
1089
+ }
1090
+ return { status: 200, body: { action: "open", url } }
1091
+ }
1092
+ // the same steps as a desktop launch (server/socket.js), so logs, launch counts and the home screen agree
1093
+ if (api.logSessions && typeof api.logSessions.reserveLaunch === "function") {
1094
+ await api.logSessions.reserveLaunch(target.uri)
1095
+ }
1096
+ if (!this.kernel.status(target.uri)) {
1097
+ this.appPreferences.recordLaunchByPath(target.uri, { source: "ui", timestamp: Date.now() }).catch((err) => {
1098
+ console.warn("[anywhere] launch count not recorded", err && err.message ? err.message : err)
1099
+ })
1100
+ await this.kernel.clearLog(target.uri)
1101
+ api.process({ uri: target.uri, input: target.input }).catch((err) => {
1102
+ console.error("[anywhere] launch failed", err && err.stack ? err.stack : err)
1103
+ })
1104
+ }
1105
+ return { status: 200, body: { action: "started" } }
1106
+ }
1031
1107
  async composeAnywhereStatus({ light } = {}) {
1032
1108
  const anywhere = this.kernel ? this.kernel.anywhere : null
1033
1109
  if (!anywhere) {
@@ -1042,14 +1118,13 @@ class Server {
1042
1118
  if (light) {
1043
1119
  return snap
1044
1120
  }
1045
- let entries = []
1046
- try {
1047
- entries = await this.kernel.api.listApps()
1048
- } catch (error) {
1049
- return Object.assign(snap, { phase: "error", error: `Could not list apps: ${error.message}` })
1050
- }
1051
- snap.installed = entries.length
1052
1121
  if (snap.phase === "ready") {
1122
+ let entries = []
1123
+ try {
1124
+ entries = await this.kernel.api.listApps()
1125
+ } catch (error) {
1126
+ return Object.assign(snap, { phase: "error", error: `Could not list apps: ${error.message}` })
1127
+ }
1053
1128
  const serveByLocalPort = new Map((snap.serve || []).map((entry) => [entry.local, entry]))
1054
1129
  let running = []
1055
1130
  try {
@@ -1065,7 +1140,8 @@ class Server {
1065
1140
  id: app.id,
1066
1141
  name: app.name,
1067
1142
  icon: app.icon,
1068
- state: entry ? app.state : (app.state === "ready" ? "publishing" : app.state),
1143
+ // Anywhere's own states: ready once a Serve route carries the app, publishing while it is up locally without one
1144
+ state: entry ? "ready" : (app.ready_url || app.url ? "publishing" : "starting"),
1069
1145
  remote: entry ? rewriteAnywhereHref(app.ready_url || app.url, snap.serve) : null,
1070
1146
  exposed: !!entry,
1071
1147
  })
@@ -14265,17 +14341,27 @@ class Server {
14265
14341
  agent: req.agent,
14266
14342
  })
14267
14343
  }))
14268
- // the phone-first launcher: running apps with their stable Tailscale addresses
14269
- this.app.get("/remote", ex(async (req, res) => {
14270
- res.render("remote", {
14271
- status: await this.composeAnywhereStatus(),
14272
- theme: this.theme,
14273
- })
14274
- }))
14275
14344
  this.app.get("/anywhere/status", ex(async (req, res) => {
14276
14345
  res.set("Cache-Control", "no-store")
14277
14346
  res.json(await this.composeAnywhereStatus({ light: req.query.light === "1" }))
14278
14347
  }))
14348
+ this.app.get("/anywhere/apps", ex(async (req, res) => {
14349
+ res.set("Cache-Control", "no-store")
14350
+ res.json(await this.composeAnywhereApps())
14351
+ }))
14352
+ this.app.post("/anywhere/launch", ex(async (req, res) => {
14353
+ res.set("Cache-Control", "no-store")
14354
+ if (!this.anywhereOriginAllowed(req.get("Origin"))) {
14355
+ res.status(403).json({ error: "Launching is allowed only from pinokio.co or this computer" })
14356
+ return
14357
+ }
14358
+ try {
14359
+ const { status, body } = await this.composeAnywhereLaunch(req.body || {})
14360
+ res.status(status).json(body)
14361
+ } catch (error) {
14362
+ res.status(500).json({ error: error && error.message ? error.message : String(error) })
14363
+ }
14364
+ }))
14279
14365
  const anywhereAction = (label, fn) => ex(async (req, res) => {
14280
14366
  const anywhere = this.kernel.anywhere
14281
14367
  if (!anywhere) {
@@ -1 +1 @@
1
- {"name":"Pinokio","short_name":"Pinokio","start_url":"/remote","scope":"/","display":"standalone","background_color":"#ffffff","theme_color":"#000000","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"},{"src":"/apple-touch-icon.png","sizes":"180x180","type":"image/png"}]}
1
+ {"name":"Pinokio","short_name":"Pinokio","start_url":"/","scope":"/","display":"standalone","background_color":"#ffffff","theme_color":"#000000","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"},{"src":"/apple-touch-icon.png","sizes":"180x180","type":"image/png"}]}
@@ -1,4 +1,3 @@
1
- <a class="btn2 home-server-popover__trigger" href="/remote" data-tippy-content="Launcher" title="Launcher" aria-label="Launcher"><i class="fa-solid fa-table-cells-large"></i></a>
2
1
  <div class="home-server-popover" data-home-server-popover data-home-server-status="loading">
3
2
  <button
4
3
  type="button"
@@ -0,0 +1,218 @@
1
+ const assert = require('node:assert/strict')
2
+ const path = require('node:path')
3
+ const test = require('node:test')
4
+ const Server = require('../server/index.js')
5
+ const Anywhere = require('../kernel/anywhere')
6
+ const { buildServeConfig, describeServeConfig } = require('../kernel/anywhere/serve')
7
+
8
+ // The Anywhere API (spec/requirements/pinokio-anywhere-api.md) with its
9
+ // collaborators stubbed: status, the installed catalog and launch.
10
+
11
+ const readyAnywhere = () => {
12
+ const anywhere = new Anywhere({})
13
+ anywhere.config = { enabled: true }
14
+ anywhere.lastStatus = { Self: { DNSName: 'pinokio.tail1.ts.net.' } }
15
+ anywhere.lastServe = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', targets: [{ listen: 43000, target: 7860 }] })
16
+ anywhere.snapshot = async () => ({ phase: 'ready', dnsName: 'pinokio.tail1.ts.net', serve: describeServeConfig(anywhere.lastServe) })
17
+ return anywhere
18
+ }
19
+
20
+ test('status carries app ids and complete remote addresses, and no installed count', async () => {
21
+ const anywhere = readyAnywhere()
22
+ let readyUrl
23
+ const fake = {
24
+ kernel: { anywhere, api: { listApps: async () => [{ id: 'comfy' }, { id: 'forge' }, { id: 'idle' }] } },
25
+ // Home Server states (pending: up locally, no external address) never leak into Anywhere's
26
+ collectHomeServerApps: async () => [
27
+ { id: 'comfy', name: 'ComfyUI', icon: '/c.png', ready_url: readyUrl, state: 'pending' },
28
+ { id: 'forge', name: 'Forge', icon: '/f.png', ready_url: '', state: 'starting' },
29
+ { id: 'unrouted', name: 'Unrouted', icon: '/u.png', ready_url: 'http://127.0.0.1:9999', state: 'pending' },
30
+ ],
31
+ portFromUrl: Server.prototype.portFromUrl,
32
+ }
33
+ for (const [url, expected] of [
34
+ ['http://127.0.0.1:7860/ui/?token=keep%20me#view', 'https://pinokio.tail1.ts.net:43000/ui/?token=keep%20me#view'],
35
+ ['http://127.0.0.1:7860', 'https://pinokio.tail1.ts.net:43000/'],
36
+ ]) {
37
+ readyUrl = url
38
+ const status = await Server.prototype.composeAnywhereStatus.call(fake, {})
39
+ assert.equal(status.installed, undefined)
40
+ assert.deepEqual(status.apps.map((a) => [a.id, a.state, a.remote]), [['comfy', 'ready', expected], ['forge', 'starting', null], ['unrouted', 'publishing', null]])
41
+ }
42
+ const light = await Server.prototype.composeAnywhereStatus.call(fake, { light: true })
43
+ assert.equal(light.apps, undefined, 'the light status is unchanged')
44
+ })
45
+
46
+ test('remote links use captured routes when Anywhere stops during app collection', async () => {
47
+ for (const stage of ['list', 'runtime']) {
48
+ const anywhere = readyAnywhere()
49
+ const stop = () => { anywhere.config.enabled = false; anywhere.lastServe = undefined; anywhere.lastStatus = null }
50
+ const fake = {
51
+ kernel: { anywhere, api: { listApps: async () => { if (stage === 'list') stop(); return [{ id: 'app' }] } } },
52
+ collectHomeServerApps: async () => {
53
+ if (stage === 'runtime') stop()
54
+ return [{ id: 'app', state: 'ready', ready_url: 'http://127.0.0.1:7860/ui/?token=keep%20me#view' }]
55
+ },
56
+ portFromUrl: Server.prototype.portFromUrl,
57
+ }
58
+ const status = await Server.prototype.composeAnywhereStatus.call(fake)
59
+ assert.equal(status.apps[0].remote, 'https://pinokio.tail1.ts.net:43000/ui/?token=keep%20me#view', stage)
60
+ assert.equal(status.apps[0].exposed, true)
61
+ }
62
+ })
63
+
64
+ test('apps are listed only in the ready phase, and a failed listing is an error', async () => {
65
+ for (const phase of ['setup', 'off', 'starting', 'login', 'machine_auth', 'https', 'error']) {
66
+ let listed = false
67
+ const status = await Server.prototype.composeAnywhereStatus.call({
68
+ kernel: { anywhere: { snapshot: async () => ({ phase }) }, api: { listApps: async () => { listed = true; return [] } } },
69
+ })
70
+ assert.equal(status.apps, undefined, phase)
71
+ assert.equal(listed, false, phase)
72
+ }
73
+ const failed = await Server.prototype.composeAnywhereStatus.call({
74
+ kernel: { anywhere: { snapshot: async () => ({ phase: 'ready' }) }, api: { listApps: async () => { throw new Error('unavailable') } } },
75
+ })
76
+ assert.equal(failed.phase, 'error')
77
+ assert.match(failed.error, /unavailable/)
78
+ assert.equal(failed.apps, undefined)
79
+ })
80
+
81
+ test('status reports runtime inspection failures while Home Server keeps its existing behavior', async () => {
82
+ const fake = {
83
+ kernel: {
84
+ path: (...parts) => path.join('/managed', ...parts),
85
+ anywhere: { snapshot: async () => ({ phase: 'ready', enabled: true, serve: [] }) },
86
+ api: { listApps: async () => [{ id: 'comfy', name: 'ComfyUI' }] },
87
+ },
88
+ appRegistry: { collectAppRuntime() { throw new Error('runtime inspection failed') } },
89
+ collectHomeServerApps: Server.prototype.collectHomeServerApps,
90
+ portFromUrl: Server.prototype.portFromUrl,
91
+ }
92
+ const status = await Server.prototype.composeAnywhereStatus.call(fake)
93
+ assert.equal(status.phase, 'error')
94
+ assert.match(status.error, /runtime inspection failed/)
95
+ assert.equal(status.apps, undefined)
96
+ assert.deepEqual(await fake.collectHomeServerApps(), [])
97
+ })
98
+
99
+ test('the catalog maps installed apps to id, name and icon', async () => {
100
+ const fake = { kernel: { api: { listApps: async () => [
101
+ { id: 'comfy', name: 'comfy', title: 'ComfyUI', icon: '/api/comfy/icon.png', description: 'x' },
102
+ { id: 'bare', name: 'bare', title: '', icon: '/pinokio-black.png' },
103
+ ] } } }
104
+ assert.deepEqual(await Server.prototype.composeAnywhereApps.call(fake), { apps: [
105
+ { id: 'comfy', name: 'ComfyUI', icon: '/api/comfy/icon.png' },
106
+ { id: 'bare', name: 'bare', icon: '/pinokio-black.png' },
107
+ ] })
108
+ })
109
+
110
+ // a launch harness: one app "comfy" at /managed/api/comfy whose menu is set per case;
111
+ // kernel.status mirrors the kernel's session-aware running check
112
+ const launchHarness = ({ menu = [], running = {}, anywhere = readyAnywhere() } = {}) => {
113
+ const calls = { process: [], cleared: [], reserved: [], counted: [] }
114
+ const api = {
115
+ exists: async (p) => p === '/managed/api/comfy',
116
+ launcher_path: async (name) => `/managed/api/${name}`,
117
+ launcher: async () => ({ script: { menu } }),
118
+ running,
119
+ logSessions: { reserveLaunch: async (uri) => { calls.reserved.push(uri) } },
120
+ process: (req) => { calls.process.push(req); return Promise.resolve() },
121
+ }
122
+ const server = Object.assign(Object.create(Server.prototype), {
123
+ kernel: {
124
+ api, anywhere,
125
+ path: (...parts) => path.posix.join('/managed', ...parts),
126
+ status: (uri) => Object.entries(running).some(([key, value]) => value && (key === uri || key.startsWith(uri + '&session='))),
127
+ clearLog: async (id) => { calls.cleared.push(id) },
128
+ },
129
+ appPreferences: { recordLaunchByPath: async (p, options) => { calls.counted.push([p, options.source]) } },
130
+ })
131
+ return { server, calls, launch: (body) => server.composeAnywhereLaunch(body) }
132
+ }
133
+
134
+ test('launch opens a loopback URL default at its Anywhere address, null until Serve carries it, other URLs as they are', async () => {
135
+ const carried = launchHarness({ menu: [{ default: true, href: 'http://127.0.0.1:7860/?x=1' }] })
136
+ assert.deepEqual(await carried.launch({ id: 'comfy' }), { status: 200, body: { action: 'open', url: 'https://pinokio.tail1.ts.net:43000/?x=1' } })
137
+ const uncarried = launchHarness({ menu: [{ default: true, href: 'http://127.0.0.1:9999/' }] })
138
+ assert.deepEqual(await uncarried.launch({ id: 'comfy' }), { status: 200, body: { action: 'open', url: null } })
139
+ const elsewhere = launchHarness({ menu: [{ default: true, href: 'https://docs.example/x' }] })
140
+ assert.deepEqual(await elsewhere.launch({ id: 'comfy' }), { status: 200, body: { action: 'open', url: 'https://docs.example/x' } })
141
+ assert.deepEqual(carried.calls.process, [])
142
+ })
143
+
144
+ test('launch starts a script default as a desktop launch would, once', async () => {
145
+ const h = launchHarness({ menu: [{ default: true, href: 'start.js?mode=fast' }] })
146
+ assert.deepEqual(await h.launch({ id: 'comfy' }), { status: 200, body: { action: 'started' } })
147
+ assert.deepEqual(h.calls.reserved, ['/managed/api/comfy/start.js'])
148
+ assert.deepEqual(h.calls.counted, [['/managed/api/comfy/start.js', 'ui']])
149
+ assert.deepEqual(h.calls.cleared, ['/managed/api/comfy/start.js'])
150
+ assert.deepEqual(h.calls.process, [{ uri: '/managed/api/comfy/start.js', input: { mode: 'fast' } }])
151
+ for (const key of ['/managed/api/comfy/start.js', '/managed/api/comfy/start.js&session=abc']) {
152
+ const already = launchHarness({ menu: [{ default: true, href: 'start.js' }], running: { [key]: true } })
153
+ assert.deepEqual(await already.launch({ id: 'comfy' }), { status: 200, body: { action: 'started' } })
154
+ assert.deepEqual(already.calls.process, [], `${key}: not started again`)
155
+ assert.deepEqual(already.calls.cleared, [], `${key}: log kept`)
156
+ }
157
+ })
158
+
159
+ const menu = [
160
+ { text: 'Install', icon: 'fa-solid fa-plug', href: 'install.js' },
161
+ { text: 'Start', icon: 'fa-solid fa-power-off', menu: [
162
+ { text: 'Start', href: 'start.js' },
163
+ { text: 'Start (low VRAM)', href: 'start.js', params: { lowvram: true } },
164
+ ] },
165
+ { text: 'Web UI', icon: 'fa-solid fa-rocket', href: 'http://127.0.0.1:7860' },
166
+ { text: 'Edit start', href: 'start.js?fs=view' },
167
+ { text: 'View source', href: 'start.js?mode=source' },
168
+ { text: 'Models', href: 'models?fs=true' },
169
+ { text: 'README', href: 'README.md?raw=true' },
170
+ { text: 'Section' },
171
+ ]
172
+
173
+ test('launch returns the flattened launchable menu when there is no default', async () => {
174
+ const h = launchHarness({ menu })
175
+ assert.deepEqual(await h.launch({ id: 'comfy' }), { status: 200, body: { action: 'menu', menu: [
176
+ { text: 'Install', icon: 'fa-solid fa-plug', href: 'install.js' },
177
+ { text: 'Start', icon: '', href: 'start.js' },
178
+ { text: 'Start (low VRAM)', icon: '', href: 'start.js?lowvram=true' },
179
+ { text: 'Web UI', icon: 'fa-solid fa-rocket', href: 'http://127.0.0.1:7860' },
180
+ ] } })
181
+ assert.deepEqual(h.calls.process, [])
182
+ })
183
+
184
+ test('launch runs a chosen menu item with its input, and rejects anything else', async () => {
185
+ const h = launchHarness({ menu })
186
+ assert.deepEqual(await h.launch({ id: 'comfy', href: 'start.js?lowvram=true' }), { status: 200, body: { action: 'started' } })
187
+ assert.deepEqual(h.calls.process, [{ uri: '/managed/api/comfy/start.js', input: { lowvram: 'true' } }])
188
+ assert.deepEqual(await h.launch({ id: 'comfy', href: 'http://127.0.0.1:7860' }), { status: 200, body: { action: 'open', url: 'https://pinokio.tail1.ts.net:43000/' } })
189
+ for (const href of ['start.js?fs=view', 'start.js?mode=source', 'models?fs=true', 'reset.js']) {
190
+ assert.equal((await h.launch({ id: 'comfy', href })).status, 400, href)
191
+ }
192
+ for (const id of ['nope', '../comfy', 'comfy/start.js', 'comfy/..', '.', '..', '', 7, undefined]) {
193
+ assert.equal((await h.launch({ id })).status, 400, String(id))
194
+ }
195
+ assert.equal(h.calls.process.length, 1)
196
+ })
197
+
198
+ // drive-relative ids ("D:", "D:comfy") resolve outside api/ on Windows; the containment rule
199
+ // uses the platform path module, so this case is only real on a Windows host
200
+ test('a drive-relative app id is refused on Windows', { skip: process.platform !== 'win32' }, async () => {
201
+ const harness = launchHarness({ menu: [{ default: true, href: 'start.js' }] })
202
+ harness.server.kernel.path = (...parts) => path.resolve('C:\\u\\pinokio', ...parts)
203
+ harness.server.kernel.api.exists = async () => true
204
+ assert.equal((await harness.launch({ id: 'comfy' })).status, 200)
205
+ for (const id of ['D:', 'D:comfy', 'C:\\u\\pinokio\\api\\comfy', '..\\comfy']) {
206
+ assert.equal((await harness.launch({ id })).status, 400, id)
207
+ }
208
+ })
209
+
210
+ test('launch is allowed from pinokio.co and this computer only', () => {
211
+ const server = Object.assign(Object.create(Server.prototype), { kernel: { anywhere: readyAnywhere() } })
212
+ for (const origin of [undefined, 'https://pinokio.co', 'http://localhost:42000', 'http://127.0.0.1:42000', 'https://pinokio.tail1.ts.net']) {
213
+ assert.equal(server.anywhereOriginAllowed(origin), true, String(origin))
214
+ }
215
+ for (const origin of ['https://evil.example', 'http://pinokio.co', 'https://pinokio.co.evil.example', 'http://pinokio.tail1.ts.net', 'not a url']) {
216
+ assert.equal(server.anywhereOriginAllowed(origin), false, origin)
217
+ }
218
+ })
@@ -1,105 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <title>Pinokio</title>
5
- <meta charset="UTF-8">
6
- <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
7
- <base target="_top">
8
- <% if (locals.anywhere_host) { %>
9
- <link rel="manifest" href="/anywhere.webmanifest">
10
- <link rel="apple-touch-icon" href="/apple-touch-icon.png">
11
- <meta name="apple-mobile-web-app-capable" content="yes">
12
- <meta name="apple-mobile-web-app-title" content="Pinokio">
13
- <% } %>
14
- <link href="/css/fontawesome.min.css" rel="stylesheet">
15
- <link href="/css/solid.min.css" rel="stylesheet">
16
- <style>
17
- :root { --bg: #f6f5f1; --card: #fff; --text: #1a1a1a; --muted: #6b6a66; --line: #dcdad2; --accent: #1a1a1a; --on: #fff; }
18
- body.dark { --bg: #141414; --card: #1e1e1e; --text: #f1f1f1; --muted: #a8a69f; --line: #333; --accent: #f1f1f1; --on: #141414; }
19
- * { box-sizing: border-box; }
20
- body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-text-size-adjust: 100%; }
21
- main { max-width: 480px; margin: 0 auto; padding: 24px 16px 48px; }
22
- h1 { font-size: 22px; font-weight: 600; margin: 0 0 18px; }
23
- h2 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin: 0 0 8px; }
24
- .card { display: flex; align-items: center; gap: 12px; background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 12px; margin: 0 0 10px; }
25
- .card img { width: 44px; height: 44px; border-radius: 10px; object-fit: cover; background: var(--bg); flex: none; }
26
- .card .body { flex: 1; min-width: 0; }
27
- .card .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
28
- .card .sub { font-size: 13px; color: var(--muted); }
29
- .card .sub a { color: inherit; }
30
- .open { flex: none; padding: 10px 16px; border-radius: 10px; background: var(--accent); color: var(--on); font-weight: 600; text-decoration: none; }
31
- .state { flex: none; font-size: 13px; color: var(--muted); }
32
- .notice { text-align: center; padding: 28px 16px; color: var(--muted); }
33
- .notice strong { display: block; color: var(--text); font-size: 17px; margin-bottom: 4px; }
34
- .door { display: flex; align-items: center; gap: 12px; border-radius: 14px; padding: 14px; text-decoration: none; color: var(--text); margin-top: 14px; }
35
- .door.dashed { border: 1px dashed var(--muted); }
36
- .door.solid { background: var(--accent); color: var(--on); }
37
- .door .body { flex: 1; }
38
- .door .title { font-weight: 600; }
39
- .door .cap { font-size: 13px; opacity: 0.75; }
40
- .door i { font-size: 18px; }
41
- .full { display: block; text-align: center; margin-top: 18px; color: var(--muted); font-size: 14px; text-decoration: none; }
42
- .full i { margin-right: 6px; }
43
- [hidden] { display: none !important; }
44
- </style>
45
- </head>
46
- <body class="<%=theme%>">
47
- <main>
48
- <h1>Pinokio</h1>
49
- <section id="running" hidden>
50
- <h2>Running now</h2>
51
- <div id="cards"></div>
52
- </section>
53
- <div id="notice" class="notice" hidden></div>
54
- <a id="door" class="door" href="/home">
55
- <i class="fa-solid fa-plus"></i>
56
- <div class="body"><div class="title" id="door-title"></div><div class="cap" id="door-cap"></div></div>
57
- <i class="fa-solid fa-chevron-right"></i>
58
- </a>
59
- <a class="full" href="/home"><i class="fa-solid fa-house"></i> Open full Pinokio</a>
60
- </main>
61
- <script>
62
- (() => {
63
- let current = <%- JSON.stringify(status || {}).replace(/</g, '\\u003c') %>
64
- const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
65
- const port = (url) => { try { return new URL(url).port } catch (_) { return '' } }
66
- const render = (status) => {
67
- current = status || {}
68
- const apps = Array.isArray(current.apps) ? current.apps : []
69
- const available = current.phase === 'ready' && Array.isArray(current.apps)
70
- document.getElementById('running').hidden = apps.length === 0
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>`
76
- document.getElementById('cards').innerHTML = apps.map((app) => `
77
- <div class="card">
78
- <img src="${esc(app.icon || '/pinokio-black.png')}" alt="">
79
- <div class="body">
80
- <div class="name">${esc(app.name)}</div>
81
- <div class="sub">${app.remote ? ':' + esc(port(app.remote)) + ' · ' : ''}<a href="/p/${encodeURIComponent(app.id || '')}">${app.remote ? 'manage' : 'terminal'}</a></div>
82
- </div>
83
- ${app.remote ? `<a class="open" href="${esc(app.remote)}">Open</a>` : `<span class="state">${esc(app.state || 'starting')}…</span>`}
84
- </div>`).join('')
85
- const door = document.getElementById('door')
86
- door.className = 'door ' + (apps.length > 0 ? 'dashed' : 'solid')
87
- document.getElementById('door-title').textContent = apps.length > 0 ? 'Start another app' : 'Start an app'
88
- document.getElementById('door-cap').textContent = typeof current.installed === 'number' ? `${current.installed} installed apps in Pinokio` : ''
89
- }
90
- const poll = async () => {
91
- try {
92
- const response = await fetch('/anywhere/status', { cache: 'no-store' })
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
- }
98
- setTimeout(poll, 5000)
99
- }
100
- render(current)
101
- setTimeout(poll, 5000)
102
- })()
103
- </script>
104
- </body>
105
- </html>
@@ -1,222 +0,0 @@
1
- const assert = require('node:assert/strict')
2
- const path = require('node:path')
3
- const test = require('node:test')
4
- const ejs = require('ejs')
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')
9
-
10
- const view = path.resolve(__dirname, '..', 'server', 'views', 'remote.ejs')
11
-
12
- // composeAnywhereStatus with its collaborators stubbed: the launcher needs the
13
- // installed count and each running app's id next to its remote address.
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
21
- const fake = {
22
- kernel: {
23
- anywhere,
24
- api: { listApps: async () => [{ id: 'comfy' }, { id: 'forge' }, { id: 'idle' }] },
25
- },
26
- collectHomeServerApps: async () => [
27
- { id: 'comfy', name: 'ComfyUI', icon: '/c.png', ready_url: readyUrl, state: 'ready' },
28
- { id: 'forge', name: 'Forge', icon: '/f.png', ready_url: '', state: 'starting' },
29
- ],
30
- portFromUrl: Server.prototype.portFromUrl,
31
- }
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
- }
41
- const light = await Server.prototype.composeAnywhereStatus.call(fake, { light: true })
42
- assert.equal(light.installed, undefined, 'the light status is unchanged')
43
- })
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
-
137
- test('the launcher page renders the running apps and one door to the full interface', async () => {
138
- const render = (status) => ejs.renderFile(view, { status, theme: 'light', locals: {} })
139
-
140
- const none = await render({ installed: 12, apps: [] })
141
- assert.match(none, /Nothing is running/)
142
- assert.match(none, /href="\/home"/)
143
- assert.match(none, /Open full Pinokio/)
144
- assert.match(none, /"installed":12/)
145
-
146
- const some = await render({ installed: 12, apps: [
147
- { id: 'comfy', name: 'ComfyUI', icon: '/c.png', state: 'ready', remote: 'https://pinokio.tail1.ts.net:43000' },
148
- { id: 'forge', name: 'Forge', icon: '/f.png', state: 'starting', remote: null },
149
- ] })
150
- assert.match(some, /"remote":"https:\/\/pinokio\.tail1\.ts\.net:43000"/)
151
- assert.match(some, /"id":"forge"/)
152
- assert.doesNotMatch(some, /<link rel="manifest"/, 'manifest tags only for the Tailscale host')
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
- })