pinokiod 8.2.15 → 8.2.17
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/kernel/api/launcher_target.js +27 -14
- package/kernel/bin/brew.js +4 -1
- package/kernel/bin/conda.js +7 -7
- package/package.json +1 -1
- package/server/index.js +100 -15
- package/server/public/anywhere.webmanifest +1 -1
- package/server/views/partials/home_server_popover.ejs +0 -1
- package/test/anywhere-api.test.js +216 -0
- package/server/views/remote.ejs +0 -105
- package/test/anywhere-remote.test.js +0 -222
|
@@ -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
|
|
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
|
|
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
|
package/kernel/bin/brew.js
CHANGED
|
@@ -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:
|
|
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/kernel/bin/conda.js
CHANGED
|
@@ -15,19 +15,19 @@ const {
|
|
|
15
15
|
isExpectedPythonPinned,
|
|
16
16
|
} = require('./conda-pins')
|
|
17
17
|
|
|
18
|
-
const MINIFORGE_RELEASE = "26.
|
|
18
|
+
const MINIFORGE_RELEASE = "26.3.2-3"
|
|
19
19
|
const MINIFORGE_BASE_URL = `https://github.com/conda-forge/miniforge/releases/download/${MINIFORGE_RELEASE}`
|
|
20
|
-
// GitHub's published SHA-256 asset digests for Miniforge 26.
|
|
20
|
+
// GitHub's published SHA-256 asset digests for Miniforge 26.3.2-3.
|
|
21
21
|
const MINIFORGE_SHA256 = {
|
|
22
22
|
darwin: {
|
|
23
|
-
x64: '
|
|
24
|
-
arm64: '
|
|
23
|
+
x64: '39273e4c89a0a1af4538010615d44ae8f44e1af41007e02def593d20f316b003',
|
|
24
|
+
arm64: '59168f1e24d0a4ad9932021170809fca836cd240e183eeeb331d5bcfc0098168',
|
|
25
25
|
},
|
|
26
26
|
linux: {
|
|
27
|
-
x64: '
|
|
28
|
-
arm64: '
|
|
27
|
+
x64: '848194851a98903134187fbb4ab50efe87b003e0c0f808f97644b7524a62bf2c',
|
|
28
|
+
arm64: '2c113a69297e612b01ca0f320c22a3107a11f2ab9b573d79ac868a175945ce29',
|
|
29
29
|
},
|
|
30
|
-
win32: { x64: '
|
|
30
|
+
win32: { x64: '14a8635465b5190537ddad6286746ffebbc55a1ed2a7bb14a506595fe3191e1e' },
|
|
31
31
|
}
|
|
32
32
|
const CONDA_ROOT_DIR = "miniforge"
|
|
33
33
|
const LEGACY_CONDA_ROOT_DIR = "miniconda"
|
package/package.json
CHANGED
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 {
|
|
@@ -14265,17 +14340,27 @@ class Server {
|
|
|
14265
14340
|
agent: req.agent,
|
|
14266
14341
|
})
|
|
14267
14342
|
}))
|
|
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
14343
|
this.app.get("/anywhere/status", ex(async (req, res) => {
|
|
14276
14344
|
res.set("Cache-Control", "no-store")
|
|
14277
14345
|
res.json(await this.composeAnywhereStatus({ light: req.query.light === "1" }))
|
|
14278
14346
|
}))
|
|
14347
|
+
this.app.get("/anywhere/apps", ex(async (req, res) => {
|
|
14348
|
+
res.set("Cache-Control", "no-store")
|
|
14349
|
+
res.json(await this.composeAnywhereApps())
|
|
14350
|
+
}))
|
|
14351
|
+
this.app.post("/anywhere/launch", ex(async (req, res) => {
|
|
14352
|
+
res.set("Cache-Control", "no-store")
|
|
14353
|
+
if (!this.anywhereOriginAllowed(req.get("Origin"))) {
|
|
14354
|
+
res.status(403).json({ error: "Launching is allowed only from pinokio.co or this computer" })
|
|
14355
|
+
return
|
|
14356
|
+
}
|
|
14357
|
+
try {
|
|
14358
|
+
const { status, body } = await this.composeAnywhereLaunch(req.body || {})
|
|
14359
|
+
res.status(status).json(body)
|
|
14360
|
+
} catch (error) {
|
|
14361
|
+
res.status(500).json({ error: error && error.message ? error.message : String(error) })
|
|
14362
|
+
}
|
|
14363
|
+
}))
|
|
14279
14364
|
const anywhereAction = (label, fn) => ex(async (req, res) => {
|
|
14280
14365
|
const anywhere = this.kernel.anywhere
|
|
14281
14366
|
if (!anywhere) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"Pinokio","short_name":"Pinokio","start_url":"/
|
|
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,216 @@
|
|
|
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
|
+
collectHomeServerApps: async () => [
|
|
26
|
+
{ id: 'comfy', name: 'ComfyUI', icon: '/c.png', ready_url: readyUrl, state: 'ready' },
|
|
27
|
+
{ id: 'forge', name: 'Forge', icon: '/f.png', ready_url: '', state: 'starting' },
|
|
28
|
+
],
|
|
29
|
+
portFromUrl: Server.prototype.portFromUrl,
|
|
30
|
+
}
|
|
31
|
+
for (const [url, expected] of [
|
|
32
|
+
['http://127.0.0.1:7860/ui/?token=keep%20me#view', 'https://pinokio.tail1.ts.net:43000/ui/?token=keep%20me#view'],
|
|
33
|
+
['http://127.0.0.1:7860', 'https://pinokio.tail1.ts.net:43000/'],
|
|
34
|
+
]) {
|
|
35
|
+
readyUrl = url
|
|
36
|
+
const status = await Server.prototype.composeAnywhereStatus.call(fake, {})
|
|
37
|
+
assert.equal(status.installed, undefined)
|
|
38
|
+
assert.deepEqual(status.apps.map((a) => [a.id, a.remote]), [['comfy', expected], ['forge', null]])
|
|
39
|
+
}
|
|
40
|
+
const light = await Server.prototype.composeAnywhereStatus.call(fake, { light: true })
|
|
41
|
+
assert.equal(light.apps, undefined, 'the light status is unchanged')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('remote links use captured routes when Anywhere stops during app collection', async () => {
|
|
45
|
+
for (const stage of ['list', 'runtime']) {
|
|
46
|
+
const anywhere = readyAnywhere()
|
|
47
|
+
const stop = () => { anywhere.config.enabled = false; anywhere.lastServe = undefined; anywhere.lastStatus = null }
|
|
48
|
+
const fake = {
|
|
49
|
+
kernel: { anywhere, api: { listApps: async () => { if (stage === 'list') stop(); return [{ id: 'app' }] } } },
|
|
50
|
+
collectHomeServerApps: async () => {
|
|
51
|
+
if (stage === 'runtime') stop()
|
|
52
|
+
return [{ id: 'app', state: 'ready', ready_url: 'http://127.0.0.1:7860/ui/?token=keep%20me#view' }]
|
|
53
|
+
},
|
|
54
|
+
portFromUrl: Server.prototype.portFromUrl,
|
|
55
|
+
}
|
|
56
|
+
const status = await Server.prototype.composeAnywhereStatus.call(fake)
|
|
57
|
+
assert.equal(status.apps[0].remote, 'https://pinokio.tail1.ts.net:43000/ui/?token=keep%20me#view', stage)
|
|
58
|
+
assert.equal(status.apps[0].exposed, true)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('apps are listed only in the ready phase, and a failed listing is an error', async () => {
|
|
63
|
+
for (const phase of ['setup', 'off', 'starting', 'login', 'machine_auth', 'https', 'error']) {
|
|
64
|
+
let listed = false
|
|
65
|
+
const status = await Server.prototype.composeAnywhereStatus.call({
|
|
66
|
+
kernel: { anywhere: { snapshot: async () => ({ phase }) }, api: { listApps: async () => { listed = true; return [] } } },
|
|
67
|
+
})
|
|
68
|
+
assert.equal(status.apps, undefined, phase)
|
|
69
|
+
assert.equal(listed, false, phase)
|
|
70
|
+
}
|
|
71
|
+
const failed = await Server.prototype.composeAnywhereStatus.call({
|
|
72
|
+
kernel: { anywhere: { snapshot: async () => ({ phase: 'ready' }) }, api: { listApps: async () => { throw new Error('unavailable') } } },
|
|
73
|
+
})
|
|
74
|
+
assert.equal(failed.phase, 'error')
|
|
75
|
+
assert.match(failed.error, /unavailable/)
|
|
76
|
+
assert.equal(failed.apps, undefined)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('status reports runtime inspection failures while Home Server keeps its existing behavior', async () => {
|
|
80
|
+
const fake = {
|
|
81
|
+
kernel: {
|
|
82
|
+
path: (...parts) => path.join('/managed', ...parts),
|
|
83
|
+
anywhere: { snapshot: async () => ({ phase: 'ready', enabled: true, serve: [] }) },
|
|
84
|
+
api: { listApps: async () => [{ id: 'comfy', name: 'ComfyUI' }] },
|
|
85
|
+
},
|
|
86
|
+
appRegistry: { collectAppRuntime() { throw new Error('runtime inspection failed') } },
|
|
87
|
+
collectHomeServerApps: Server.prototype.collectHomeServerApps,
|
|
88
|
+
portFromUrl: Server.prototype.portFromUrl,
|
|
89
|
+
}
|
|
90
|
+
const status = await Server.prototype.composeAnywhereStatus.call(fake)
|
|
91
|
+
assert.equal(status.phase, 'error')
|
|
92
|
+
assert.match(status.error, /runtime inspection failed/)
|
|
93
|
+
assert.equal(status.apps, undefined)
|
|
94
|
+
assert.deepEqual(await fake.collectHomeServerApps(), [])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('the catalog maps installed apps to id, name and icon', async () => {
|
|
98
|
+
const fake = { kernel: { api: { listApps: async () => [
|
|
99
|
+
{ id: 'comfy', name: 'comfy', title: 'ComfyUI', icon: '/api/comfy/icon.png', description: 'x' },
|
|
100
|
+
{ id: 'bare', name: 'bare', title: '', icon: '/pinokio-black.png' },
|
|
101
|
+
] } } }
|
|
102
|
+
assert.deepEqual(await Server.prototype.composeAnywhereApps.call(fake), { apps: [
|
|
103
|
+
{ id: 'comfy', name: 'ComfyUI', icon: '/api/comfy/icon.png' },
|
|
104
|
+
{ id: 'bare', name: 'bare', icon: '/pinokio-black.png' },
|
|
105
|
+
] })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// a launch harness: one app "comfy" at /managed/api/comfy whose menu is set per case;
|
|
109
|
+
// kernel.status mirrors the kernel's session-aware running check
|
|
110
|
+
const launchHarness = ({ menu = [], running = {}, anywhere = readyAnywhere() } = {}) => {
|
|
111
|
+
const calls = { process: [], cleared: [], reserved: [], counted: [] }
|
|
112
|
+
const api = {
|
|
113
|
+
exists: async (p) => p === '/managed/api/comfy',
|
|
114
|
+
launcher_path: async (name) => `/managed/api/${name}`,
|
|
115
|
+
launcher: async () => ({ script: { menu } }),
|
|
116
|
+
running,
|
|
117
|
+
logSessions: { reserveLaunch: async (uri) => { calls.reserved.push(uri) } },
|
|
118
|
+
process: (req) => { calls.process.push(req); return Promise.resolve() },
|
|
119
|
+
}
|
|
120
|
+
const server = Object.assign(Object.create(Server.prototype), {
|
|
121
|
+
kernel: {
|
|
122
|
+
api, anywhere,
|
|
123
|
+
path: (...parts) => path.posix.join('/managed', ...parts),
|
|
124
|
+
status: (uri) => Object.entries(running).some(([key, value]) => value && (key === uri || key.startsWith(uri + '&session='))),
|
|
125
|
+
clearLog: async (id) => { calls.cleared.push(id) },
|
|
126
|
+
},
|
|
127
|
+
appPreferences: { recordLaunchByPath: async (p, options) => { calls.counted.push([p, options.source]) } },
|
|
128
|
+
})
|
|
129
|
+
return { server, calls, launch: (body) => server.composeAnywhereLaunch(body) }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
test('launch opens a loopback URL default at its Anywhere address, null until Serve carries it, other URLs as they are', async () => {
|
|
133
|
+
const carried = launchHarness({ menu: [{ default: true, href: 'http://127.0.0.1:7860/?x=1' }] })
|
|
134
|
+
assert.deepEqual(await carried.launch({ id: 'comfy' }), { status: 200, body: { action: 'open', url: 'https://pinokio.tail1.ts.net:43000/?x=1' } })
|
|
135
|
+
const uncarried = launchHarness({ menu: [{ default: true, href: 'http://127.0.0.1:9999/' }] })
|
|
136
|
+
assert.deepEqual(await uncarried.launch({ id: 'comfy' }), { status: 200, body: { action: 'open', url: null } })
|
|
137
|
+
const elsewhere = launchHarness({ menu: [{ default: true, href: 'https://docs.example/x' }] })
|
|
138
|
+
assert.deepEqual(await elsewhere.launch({ id: 'comfy' }), { status: 200, body: { action: 'open', url: 'https://docs.example/x' } })
|
|
139
|
+
assert.deepEqual(carried.calls.process, [])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('launch starts a script default as a desktop launch would, once', async () => {
|
|
143
|
+
const h = launchHarness({ menu: [{ default: true, href: 'start.js?mode=fast' }] })
|
|
144
|
+
assert.deepEqual(await h.launch({ id: 'comfy' }), { status: 200, body: { action: 'started' } })
|
|
145
|
+
assert.deepEqual(h.calls.reserved, ['/managed/api/comfy/start.js'])
|
|
146
|
+
assert.deepEqual(h.calls.counted, [['/managed/api/comfy/start.js', 'ui']])
|
|
147
|
+
assert.deepEqual(h.calls.cleared, ['/managed/api/comfy/start.js'])
|
|
148
|
+
assert.deepEqual(h.calls.process, [{ uri: '/managed/api/comfy/start.js', input: { mode: 'fast' } }])
|
|
149
|
+
for (const key of ['/managed/api/comfy/start.js', '/managed/api/comfy/start.js&session=abc']) {
|
|
150
|
+
const already = launchHarness({ menu: [{ default: true, href: 'start.js' }], running: { [key]: true } })
|
|
151
|
+
assert.deepEqual(await already.launch({ id: 'comfy' }), { status: 200, body: { action: 'started' } })
|
|
152
|
+
assert.deepEqual(already.calls.process, [], `${key}: not started again`)
|
|
153
|
+
assert.deepEqual(already.calls.cleared, [], `${key}: log kept`)
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const menu = [
|
|
158
|
+
{ text: 'Install', icon: 'fa-solid fa-plug', href: 'install.js' },
|
|
159
|
+
{ text: 'Start', icon: 'fa-solid fa-power-off', menu: [
|
|
160
|
+
{ text: 'Start', href: 'start.js' },
|
|
161
|
+
{ text: 'Start (low VRAM)', href: 'start.js', params: { lowvram: true } },
|
|
162
|
+
] },
|
|
163
|
+
{ text: 'Web UI', icon: 'fa-solid fa-rocket', href: 'http://127.0.0.1:7860' },
|
|
164
|
+
{ text: 'Edit start', href: 'start.js?fs=view' },
|
|
165
|
+
{ text: 'View source', href: 'start.js?mode=source' },
|
|
166
|
+
{ text: 'Models', href: 'models?fs=true' },
|
|
167
|
+
{ text: 'README', href: 'README.md?raw=true' },
|
|
168
|
+
{ text: 'Section' },
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
test('launch returns the flattened launchable menu when there is no default', async () => {
|
|
172
|
+
const h = launchHarness({ menu })
|
|
173
|
+
assert.deepEqual(await h.launch({ id: 'comfy' }), { status: 200, body: { action: 'menu', menu: [
|
|
174
|
+
{ text: 'Install', icon: 'fa-solid fa-plug', href: 'install.js' },
|
|
175
|
+
{ text: 'Start', icon: '', href: 'start.js' },
|
|
176
|
+
{ text: 'Start (low VRAM)', icon: '', href: 'start.js?lowvram=true' },
|
|
177
|
+
{ text: 'Web UI', icon: 'fa-solid fa-rocket', href: 'http://127.0.0.1:7860' },
|
|
178
|
+
] } })
|
|
179
|
+
assert.deepEqual(h.calls.process, [])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('launch runs a chosen menu item with its input, and rejects anything else', async () => {
|
|
183
|
+
const h = launchHarness({ menu })
|
|
184
|
+
assert.deepEqual(await h.launch({ id: 'comfy', href: 'start.js?lowvram=true' }), { status: 200, body: { action: 'started' } })
|
|
185
|
+
assert.deepEqual(h.calls.process, [{ uri: '/managed/api/comfy/start.js', input: { lowvram: 'true' } }])
|
|
186
|
+
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/' } })
|
|
187
|
+
for (const href of ['start.js?fs=view', 'start.js?mode=source', 'models?fs=true', 'reset.js']) {
|
|
188
|
+
assert.equal((await h.launch({ id: 'comfy', href })).status, 400, href)
|
|
189
|
+
}
|
|
190
|
+
for (const id of ['nope', '../comfy', 'comfy/start.js', 'comfy/..', '.', '..', '', 7, undefined]) {
|
|
191
|
+
assert.equal((await h.launch({ id })).status, 400, String(id))
|
|
192
|
+
}
|
|
193
|
+
assert.equal(h.calls.process.length, 1)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// drive-relative ids ("D:", "D:comfy") resolve outside api/ on Windows; the containment rule
|
|
197
|
+
// uses the platform path module, so this case is only real on a Windows host
|
|
198
|
+
test('a drive-relative app id is refused on Windows', { skip: process.platform !== 'win32' }, async () => {
|
|
199
|
+
const harness = launchHarness({ menu: [{ default: true, href: 'start.js' }] })
|
|
200
|
+
harness.server.kernel.path = (...parts) => path.resolve('C:\\u\\pinokio', ...parts)
|
|
201
|
+
harness.server.kernel.api.exists = async () => true
|
|
202
|
+
assert.equal((await harness.launch({ id: 'comfy' })).status, 200)
|
|
203
|
+
for (const id of ['D:', 'D:comfy', 'C:\\u\\pinokio\\api\\comfy', '..\\comfy']) {
|
|
204
|
+
assert.equal((await harness.launch({ id })).status, 400, id)
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('launch is allowed from pinokio.co and this computer only', () => {
|
|
209
|
+
const server = Object.assign(Object.create(Server.prototype), { kernel: { anywhere: readyAnywhere() } })
|
|
210
|
+
for (const origin of [undefined, 'https://pinokio.co', 'http://localhost:42000', 'http://127.0.0.1:42000', 'https://pinokio.tail1.ts.net']) {
|
|
211
|
+
assert.equal(server.anywhereOriginAllowed(origin), true, String(origin))
|
|
212
|
+
}
|
|
213
|
+
for (const origin of ['https://evil.example', 'http://pinokio.co', 'https://pinokio.co.evil.example', 'http://pinokio.tail1.ts.net', 'not a url']) {
|
|
214
|
+
assert.equal(server.anywhereOriginAllowed(origin), false, origin)
|
|
215
|
+
}
|
|
216
|
+
})
|
package/server/views/remote.ejs
DELETED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
-
})
|