pinokiod 8.2.1 → 8.2.3

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.
@@ -0,0 +1,147 @@
1
+ /*
2
+ Minimal client for the tailscaled LocalAPI.
3
+
4
+ tailscaled exposes an HTTP API over a unix socket (mac/linux) or a named
5
+ pipe (Windows). We talk to it with plain node `http` by passing the socket
6
+ path as `socketPath`; node's net layer handles both unix sockets and
7
+ `\\.\pipe\...` names. The conda-forge `tailscaled` package ships only the
8
+ daemon (no `tailscale` CLI), so every operation goes through this client.
9
+
10
+ Verified against tailscaled 1.104.1 (see spec/requirements/pinokio-anywhere.md):
11
+ GET /localapi/v0/status -> ipnstate.Status JSON
12
+ GET /localapi/v0/prefs -> ipn.Prefs JSON
13
+ PATCH /localapi/v0/prefs <- ipn.MaskedPrefs ({HostnameSet:true, Hostname:"pinokio", ...})
14
+ POST /localapi/v0/login-interactive -> 204; AuthURL shows up in /status and on the IPN bus (BrowseToURL)
15
+ POST /localapi/v0/logout -> 204
16
+ GET /localapi/v0/serve-config -> ipn.ServeConfig JSON (or null) + Etag header
17
+ POST /localapi/v0/serve-config <- ipn.ServeConfig JSON, optional If-Match: <etag>
18
+ POST /localapi/v0/query-feature?feature=serve -> {Complete, Text, URL} (URL = one-click tailnet approval)
19
+ */
20
+ const http = require('http')
21
+
22
+ class LocalAPIError extends Error {
23
+ constructor(message, status, body) {
24
+ super(message)
25
+ this.name = 'LocalAPIError'
26
+ this.status = status
27
+ this.body = body
28
+ }
29
+ }
30
+
31
+ class LocalAPI {
32
+ constructor({ socketPath, timeout = 10000 } = {}) {
33
+ if (!socketPath) {
34
+ throw new Error('LocalAPI requires a socketPath')
35
+ }
36
+ this.socketPath = socketPath
37
+ this.timeout = timeout
38
+ this.base = '/localapi/v0'
39
+ }
40
+ request(method, route, { body, headers, timeout } = {}) {
41
+ const payload = typeof body === 'undefined' || body === null
42
+ ? null
43
+ : (typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body))
44
+ const reqHeaders = Object.assign({
45
+ // tailscaled rejects non-loopback origins without this on some
46
+ // transports (the Windows GUI loopback port); harmless on sockets.
47
+ 'Sec-Tailscale': 'localapi',
48
+ 'Host': 'local-tailscaled.sock',
49
+ }, headers || {})
50
+ if (payload !== null) {
51
+ reqHeaders['Content-Type'] = reqHeaders['Content-Type'] || 'application/json'
52
+ reqHeaders['Content-Length'] = Buffer.byteLength(payload)
53
+ }
54
+ return new Promise((resolve, reject) => {
55
+ const req = http.request({
56
+ socketPath: this.socketPath,
57
+ method,
58
+ path: this.base + route,
59
+ headers: reqHeaders,
60
+ }, (res) => {
61
+ const chunks = []
62
+ res.on('data', (chunk) => chunks.push(chunk))
63
+ res.on('end', () => {
64
+ const text = Buffer.concat(chunks).toString('utf8')
65
+ resolve({
66
+ status: res.statusCode,
67
+ headers: res.headers,
68
+ text,
69
+ json: () => {
70
+ if (!text || !text.trim()) return null
71
+ return JSON.parse(text)
72
+ },
73
+ })
74
+ })
75
+ res.on('error', reject)
76
+ })
77
+ req.setTimeout(timeout || this.timeout, () => {
78
+ req.destroy(new Error(`LocalAPI ${method} ${route} timed out`))
79
+ })
80
+ req.on('error', reject)
81
+ if (payload !== null) {
82
+ req.write(payload)
83
+ }
84
+ req.end()
85
+ })
86
+ }
87
+ async expect(method, route, options, okStatuses = [200, 204]) {
88
+ const res = await this.request(method, route, options)
89
+ if (!okStatuses.includes(res.status)) {
90
+ throw new LocalAPIError(`LocalAPI ${method} ${route} failed: ${res.status} ${res.text.trim()}`, res.status, res.text)
91
+ }
92
+ return res
93
+ }
94
+ async status() {
95
+ const res = await this.expect('GET', '/status')
96
+ return res.json()
97
+ }
98
+ // masked: an ipn.MaskedPrefs object, e.g. { HostnameSet: true, Hostname: 'pinokio' }
99
+ async patchPrefs(masked) {
100
+ const res = await this.expect('PATCH', '/prefs', { body: masked })
101
+ return res.json()
102
+ }
103
+ async loginInteractive() {
104
+ await this.expect('POST', '/login-interactive', { body: '' })
105
+ return true
106
+ }
107
+ async logout() {
108
+ await this.expect('POST', '/logout', { body: '' })
109
+ return true
110
+ }
111
+ // Restarts the backend's login state machine (what `tailscale up --force-reauth`
112
+ // does): a stale or invalidated login URL is dropped and a fresh one is minted
113
+ // on the next login-interactive.
114
+ async start(options = {}) {
115
+ await this.expect('POST', '/start', { body: options })
116
+ return true
117
+ }
118
+ async serveConfig() {
119
+ const res = await this.expect('GET', '/serve-config')
120
+ return { config: res.json(), etag: res.headers.etag || res.headers.Etag || null }
121
+ }
122
+ async setServeConfig(config, etag) {
123
+ const headers = {}
124
+ if (etag) {
125
+ headers['If-Match'] = etag
126
+ }
127
+ await this.expect('POST', '/serve-config', { body: config || {}, headers })
128
+ return true
129
+ }
130
+ async queryFeature(feature) {
131
+ const res = await this.request('POST', `/query-feature?feature=${encodeURIComponent(feature)}`, { body: '' })
132
+ if (res.status !== 200) {
133
+ throw new LocalAPIError(`query-feature ${feature} failed: ${res.status} ${res.text.trim()}`, res.status, res.text)
134
+ }
135
+ return res.json()
136
+ }
137
+ async ping() {
138
+ try {
139
+ const res = await this.request('GET', '/status', { timeout: 2000 })
140
+ return res.status === 200
141
+ } catch (e) {
142
+ return false
143
+ }
144
+ }
145
+ }
146
+
147
+ module.exports = { LocalAPI, LocalAPIError }
@@ -0,0 +1,51 @@
1
+ /*
2
+ Stable remote ports for Pinokio Anywhere.
3
+
4
+ An app may bind a different local port from one launch to the next, but its
5
+ Tailscale address must not move: bookmarks, home-screen icons and links all
6
+ point at https://<node>.<tailnet>.ts.net:<port>. So every app is given a
7
+ remote port once, kept in ~/pinokio/anywhere/ports.json, and Serve maps that
8
+ fixed port to whatever local port the app has right now.
9
+
10
+ Remote ports live inside tailscaled's own network stack, so they never need
11
+ to be free on the machine itself.
12
+ */
13
+ const fs = require('fs')
14
+ const path = require('path')
15
+
16
+ const FIRST_PORT = 43000
17
+ const LAST_PORT = 43999
18
+
19
+ async function loadPortMap(file) {
20
+ try {
21
+ const map = JSON.parse(await fs.promises.readFile(file, 'utf8'))
22
+ return map && typeof map === 'object' && !Array.isArray(map) ? map : {}
23
+ } catch (_) {
24
+ return {}
25
+ }
26
+ }
27
+
28
+ async function savePortMap(file, map) {
29
+ await fs.promises.mkdir(path.dirname(file), { recursive: true })
30
+ await fs.promises.writeFile(file, JSON.stringify(map, null, 2))
31
+ }
32
+
33
+ // Returns a copy of `map` with a port for every key, giving new keys the
34
+ // lowest port not already taken. Existing assignments are never changed.
35
+ function assignPorts(map, keys) {
36
+ const out = Object.assign({}, map)
37
+ const used = new Set(Object.values(out).map(Number))
38
+ let next = FIRST_PORT
39
+ let changed = false
40
+ for (const key of keys) {
41
+ if (Number.isInteger(out[key])) continue
42
+ while (used.has(next)) next += 1
43
+ if (next > LAST_PORT) throw new Error('Pinokio Anywhere has no free remote ports left')
44
+ out[key] = next
45
+ used.add(next)
46
+ changed = true
47
+ }
48
+ return { map: out, changed }
49
+ }
50
+
51
+ module.exports = { loadPortMap, savePortMap, assignPorts }
@@ -0,0 +1,95 @@
1
+ /*
2
+ Builds the ipn.ServeConfig that tailscaled needs so pinokiod and each running
3
+ Pinokio app are reachable over HTTPS at a fixed address.
4
+
5
+ MagicDNS has no subdomains, so routes are per port. The remote port is the
6
+ app's stable port (see ports.js), the target is whatever local port the app
7
+ has right now:
8
+
9
+ https://pinokio.<tailnet>.ts.net -> http://127.0.0.1:<pinokiod port>
10
+ https://pinokio.<tailnet>.ts.net:43000 -> http://127.0.0.1:7860 (comfy)
11
+ https://pinokio.<tailnet>.ts.net:43001 -> http://127.0.0.1:8188 (forge)
12
+
13
+ Serve-only: no AllowFunnel entries are ever emitted, so nothing is public.
14
+ */
15
+
16
+ const DEFAULT_HTTPS_PORT = 443
17
+
18
+ function normalizePort(value) {
19
+ const port = Number(value)
20
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
21
+ return null
22
+ }
23
+ return port
24
+ }
25
+
26
+ /*
27
+ options:
28
+ dnsName "pinokio.tail1234.ts.net" (no trailing dot)
29
+ serverPort pinokiod's own port (mapped to :443)
30
+ targets [{ listen, target }]: remote port -> local port
31
+ */
32
+ function buildServeConfig({ dnsName, serverPort, targets = [] } = {}) {
33
+ const host = String(dnsName || '').replace(/\.$/, '')
34
+ if (!host) {
35
+ throw new Error('buildServeConfig requires dnsName')
36
+ }
37
+ const config = { TCP: {}, Web: {} }
38
+ const add = (listenPort, targetPort) => {
39
+ config.TCP[String(listenPort)] = { HTTPS: true }
40
+ config.Web[`${host}:${listenPort}`] = {
41
+ Handlers: {
42
+ '/': { Proxy: `http://127.0.0.1:${targetPort}` }
43
+ }
44
+ }
45
+ }
46
+ const server = normalizePort(serverPort)
47
+ if (server) {
48
+ add(DEFAULT_HTTPS_PORT, server)
49
+ }
50
+ const seen = new Set([DEFAULT_HTTPS_PORT])
51
+ const entries = []
52
+ for (const raw of targets) {
53
+ const listen = normalizePort(raw && raw.listen)
54
+ const target = normalizePort(raw && raw.target)
55
+ if (!listen || !target || seen.has(listen)) continue
56
+ seen.add(listen)
57
+ entries.push({ listen, target })
58
+ }
59
+ // deterministic key order so unchanged mappings compare equal
60
+ for (const entry of entries.sort((a, b) => a.listen - b.listen)) {
61
+ add(entry.listen, entry.target)
62
+ }
63
+ return config
64
+ }
65
+
66
+ // Lists what a serve config exposes: [{ port, local, target, url, name }]
67
+ // `names` maps a remote port to the app name it was assigned to.
68
+ function describeServeConfig(config, dnsName, names = {}) {
69
+ const host = String(dnsName || '').replace(/\.$/, '')
70
+ const out = []
71
+ if (!config || !config.Web) return out
72
+ for (const key of Object.keys(config.Web)) {
73
+ const match = key.match(/^(.*):(\d+)$/)
74
+ if (!match) continue
75
+ const port = Number(match[2])
76
+ const handlers = config.Web[key].Handlers || {}
77
+ const root = handlers['/'] || {}
78
+ const local = root.Proxy ? Number((String(root.Proxy).match(/:(\d+)$/) || [])[1]) || null : null
79
+ out.push({
80
+ port,
81
+ local,
82
+ target: root.Proxy || null,
83
+ url: port === DEFAULT_HTTPS_PORT ? `https://${host || match[1]}` : `https://${host || match[1]}:${port}`,
84
+ name: names[port] || null,
85
+ })
86
+ }
87
+ out.sort((a, b) => a.port - b.port)
88
+ return out
89
+ }
90
+
91
+ function sameServeConfig(a, b) {
92
+ return JSON.stringify(a || null) === JSON.stringify(b || null)
93
+ }
94
+
95
+ module.exports = { buildServeConfig, describeServeConfig, sameServeConfig, DEFAULT_HTTPS_PORT }
@@ -270,6 +270,35 @@ module.exports = {
270
270
  ]
271
271
  }
272
272
  },
273
+ anywhere: (kernel) => {
274
+ let requirements = [
275
+ { name: "conda", },
276
+ { name: "zip", },
277
+ ]
278
+ if (platform === "darwin") {
279
+ requirements.push({ name: "brew" })
280
+ }
281
+ requirements = requirements.concat([
282
+ { name: "git", },
283
+ { name: "uv", },
284
+ { name: "node", },
285
+ { name: "tailscaled", },
286
+ { name: "py" },
287
+ { name: "bluefairy" },
288
+ ])
289
+ return {
290
+ icon: "fa-solid fa-mobile-screen",
291
+ title: "Pinokio Anywhere",
292
+ description: "Reach this Pinokio and its apps from your phone or another computer over a private Tailscale network",
293
+ requirements,
294
+ conda_requirements: [
295
+ zip_cmd,
296
+ "uv",
297
+ "git",
298
+ "tailscaled"
299
+ ]
300
+ }
301
+ },
273
302
  connect: (kernel) => {
274
303
  let requirements = [
275
304
  { name: "conda", },
@@ -0,0 +1,71 @@
1
+ const path = require('path')
2
+ const semver = require('semver')
3
+
4
+ // conda-forge ships only the daemon binary (no `tailscale` CLI); Pinokio
5
+ // drives it over its LocalAPI (see kernel/anywhere).
6
+ const TAILSCALED_SPEC = "tailscaled=1.104.1"
7
+ const TAILSCALED_REQUIREMENT = ">=1.104.0" // 1.104 added --windows-mode=dev
8
+
9
+ class Tailscaled {
10
+ description = "Installs the Tailscale daemon for Pinokio Anywhere: reach this Pinokio and its apps from your phone or another computer over a private network."
11
+ cmd() {
12
+ return TAILSCALED_SPEC
13
+ }
14
+ binPath() {
15
+ if (this.kernel.platform === "win32") {
16
+ return this.kernel.bin.path("miniforge", "Library", "bin", "tailscaled.exe")
17
+ }
18
+ return this.kernel.bin.path("miniforge", "bin", "tailscaled")
19
+ }
20
+ async install(req, ondata) {
21
+ await this.kernel.bin.exec({
22
+ message: [
23
+ // a stale libmamba index cache reports the package as missing even
24
+ // though `conda search` sees it; clearing just the index fixes that.
25
+ "conda clean -y -i",
26
+ `conda install -y --override-channels -c conda-forge ${this.cmd()}`
27
+ ]
28
+ }, ondata)
29
+ }
30
+ async installed() {
31
+ try {
32
+ const conda = this.kernel.bin.installed && this.kernel.bin.installed.conda
33
+ if (!conda || !conda.has("tailscaled")) {
34
+ return false
35
+ }
36
+ const versions = this.kernel.bin.installed.conda_versions || {}
37
+ const coerced = semver.coerce(versions.tailscaled)
38
+ if (!coerced) {
39
+ return false
40
+ }
41
+ return semver.satisfies(coerced, TAILSCALED_REQUIREMENT)
42
+ } catch (e) {
43
+ console.log("tailscaled installed check error", e)
44
+ return false
45
+ }
46
+ }
47
+ // Called by kernel.bin.init() for every module: resume the daemon when the
48
+ // user had Pinokio Anywhere switched on.
49
+ async start() {
50
+ const anywhere = this.kernel.anywhere
51
+ if (!anywhere || typeof anywhere.autostart !== "function") {
52
+ return
53
+ }
54
+ // not awaited: a slow or broken daemon must not hold up the rest of bin init
55
+ anywhere.autostart().catch((e) => {
56
+ console.log("[anywhere] autostart failed", e && e.message ? e.message : e)
57
+ })
58
+ }
59
+ async uninstall(req, ondata) {
60
+ ondata({ raw: "stopping Pinokio Anywhere\r\n" })
61
+ if (this.kernel.anywhere && typeof this.kernel.anywhere.stop === "function") {
62
+ await this.kernel.anywhere.stop().catch(() => {})
63
+ }
64
+ ondata({ raw: "cleaning up\r\n" })
65
+ await this.kernel.bin.exec({
66
+ message: `conda remove -y tailscaled`,
67
+ }, ondata)
68
+ ondata({ raw: "finished cleaning up\r\n" })
69
+ }
70
+ }
71
+ module.exports = Tailscaled
package/kernel/index.js CHANGED
@@ -35,6 +35,7 @@ const Plugin = require('./plugin')
35
35
  const Router = require("./router")
36
36
  const PinokioDomainRouter = require("./router/pinokio_domain_router")
37
37
  const Procs = require('./procs')
38
+ const Anywhere = require('./anywhere')
38
39
  const Peer = require('./peer')
39
40
  const Git = require('./git')
40
41
  const Vault = require('./vault')
@@ -711,6 +712,15 @@ class Kernel {
711
712
 
712
713
  }
713
714
  }
715
+ // 8b. Pinokio Anywhere: refresh the tailscale serve config from the apps'
716
+ // published URLs. No-op unless Anywhere is on and its daemon is running.
717
+ if (this.anywhere) {
718
+ try {
719
+ await this.anywhere.sync()
720
+ } catch (err) {
721
+ console.warn('[anywhere] sync failed:', err && err.message ? err.message : err)
722
+ }
723
+ }
714
724
  // 6. tell peers to refresh
715
725
  let changed
716
726
  let new_config = JSON.stringify(await this.peer.current_host())
@@ -1103,12 +1113,17 @@ class Kernel {
1103
1113
  if (this.peer) {
1104
1114
  this.peer.stop()
1105
1115
  }
1116
+ if (this.anywhere) {
1117
+ // re-initializing the kernel (home change): drop the old daemon
1118
+ await this.anywhere.stop().catch(() => {})
1119
+ }
1106
1120
 
1107
1121
  this.info = new Info(this)
1108
1122
  this.pipe = new Pipe(this)
1109
1123
  this.proto = new Proto(this)
1110
1124
  this.plugin = new Plugin(this)
1111
1125
  this.processes = new Procs(this)
1126
+ this.anywhere = new Anywhere(this)
1112
1127
  this.kv = new KV(this)
1113
1128
  this.cloudflare = new Cloudflare()
1114
1129
  this.peer = new Peer(this)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.2.1",
3
+ "version": "8.2.3",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -497,6 +497,13 @@ class Server {
497
497
  if (this.kernel && this.kernel.processes && this.kernel.processes.caddy_pid) {
498
498
  this.killProcessTree(this.kernel.processes.caddy_pid, 'kill caddy')
499
499
  }
500
+ if (this.kernel && this.kernel.anywhere) {
501
+ try {
502
+ this.kernel.anywhere.killSync()
503
+ } catch (error) {
504
+ console.error('Failed to kill tailscaled', error)
505
+ }
506
+ }
500
507
  this.killProcessTree(process.pid, 'kill self')
501
508
  }
502
509
  exists (s) {
@@ -1011,6 +1018,64 @@ class Server {
1011
1018
  routes.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")))
1012
1019
  return routes
1013
1020
  }
1021
+ // ---------------- Pinokio Anywhere ----------------
1022
+ portFromUrl(value) {
1023
+ if (!value || typeof value !== "string") return null
1024
+ try {
1025
+ const parsed = new URL(value)
1026
+ if (parsed.port) return Number(parsed.port)
1027
+ if (parsed.protocol === "https:") return 443
1028
+ if (parsed.protocol === "http:") return 80
1029
+ } catch (_) { }
1030
+ return null
1031
+ }
1032
+ async composeAnywhereStatus({ light } = {}) {
1033
+ const anywhere = this.kernel ? this.kernel.anywhere : null
1034
+ if (!anywhere) {
1035
+ return { phase: "setup", installed: false, enabled: false, running: false }
1036
+ }
1037
+ let snap
1038
+ try {
1039
+ snap = await anywhere.snapshot()
1040
+ } catch (error) {
1041
+ return { phase: "error", installed: true, enabled: true, running: false, error: error && error.message ? error.message : String(error) }
1042
+ }
1043
+ if (light) {
1044
+ return snap
1045
+ }
1046
+ snap.apps = []
1047
+ if (snap.phase === "ready") {
1048
+ const serveByLocalPort = new Map((snap.serve || []).map((entry) => [entry.local, entry]))
1049
+ let running = []
1050
+ try {
1051
+ running = await this.collectHomeServerApps()
1052
+ } catch (_) {
1053
+ running = []
1054
+ }
1055
+ for (const app of running) {
1056
+ const port = this.portFromUrl(app.ready_url || app.url)
1057
+ const entry = port ? serveByLocalPort.get(port) : null
1058
+ snap.apps.push({
1059
+ name: app.name,
1060
+ icon: app.icon,
1061
+ state: app.state,
1062
+ remote: entry ? entry.url : null,
1063
+ exposed: !!entry,
1064
+ })
1065
+ }
1066
+ }
1067
+ if (snap.connectUrl) {
1068
+ if (!this.anywhereQrCache || this.anywhereQrCache.url !== snap.connectUrl) {
1069
+ try {
1070
+ this.anywhereQrCache = { url: snap.connectUrl, data: await QRCode.toDataURL(snap.connectUrl, { margin: 1, scale: 6 }) }
1071
+ } catch (_) {
1072
+ this.anywhereQrCache = { url: snap.connectUrl, data: null }
1073
+ }
1074
+ }
1075
+ snap.qr = this.anywhereQrCache.data
1076
+ }
1077
+ return snap
1078
+ }
1014
1079
  async collectHomeServerApps() {
1015
1080
  const apps = []
1016
1081
  if (!this.kernel || !this.kernel.api || typeof this.kernel.api.listApps !== "function") {
@@ -4900,6 +4965,12 @@ class Server {
4900
4965
  // config.icon = "/pinokio-white.png"
4901
4966
  // }
4902
4967
  // }
4968
+ // Pinokio Anywhere: a visitor on the node's own Tailscale name gets each
4969
+ // published app's fixed https address; any other host is left for the
4970
+ // Home Server rewrite below, exactly as before.
4971
+ if (this.kernel.anywhere) {
4972
+ await this.kernel.anywhere.rewriteMenu(req.$source && req.$source.host, config.menu)
4973
+ }
4903
4974
  config = Util.rewrite_localhost(this.kernel, config, req.$source)
4904
4975
  return config
4905
4976
  } else {
@@ -6757,6 +6828,8 @@ class Server {
6757
6828
  protocol,
6758
6829
  host: forwardedHost || req.get("host")
6759
6830
  }
6831
+ // Pinokio Anywhere: only a visitor on the node's own Tailscale name gets the web-app manifest
6832
+ res.locals.anywhere_host = !!(this.kernel && this.kernel.anywhere && this.kernel.anywhere.isNodeHost(req.$source.host))
6760
6833
  next()
6761
6834
  })
6762
6835
  if (this.kernel.homedir) {
@@ -14159,6 +14232,56 @@ class Server {
14159
14232
  allow_dns_creation,
14160
14233
  })
14161
14234
  }))
14235
+ this.app.get("/anywhere", ex(async (req, res) => {
14236
+ let { requirements, install_required, requirements_pending, error } = await this.kernel.bin.check({
14237
+ bin: this.kernel.bin.preset("anywhere"),
14238
+ })
14239
+ if (!requirements_pending && install_required) {
14240
+ res.redirect("/setup/anywhere?callback=/anywhere")
14241
+ return
14242
+ }
14243
+ const anywhere = this.kernel.anywhere
14244
+ // first visit after install: switch it on, the page then walks the user
14245
+ // through sign-in. Later visits respect the on/off switch.
14246
+ if (anywhere && !requirements_pending && !(await anywhere.configured())) {
14247
+ anywhere.enable().catch((err) => {
14248
+ console.log("[anywhere] enable failed", err && err.message ? err.message : err)
14249
+ })
14250
+ }
14251
+ const status = await this.composeAnywhereStatus()
14252
+ res.render("anywhere", {
14253
+ status,
14254
+ requirements_pending,
14255
+ install_required,
14256
+ error,
14257
+ docs: this.docs,
14258
+ portal: this.portal,
14259
+ logo: this.logo,
14260
+ theme: this.theme,
14261
+ agent: req.agent,
14262
+ })
14263
+ }))
14264
+ this.app.get("/anywhere/status", ex(async (req, res) => {
14265
+ res.set("Cache-Control", "no-store")
14266
+ res.json(await this.composeAnywhereStatus({ light: req.query.light === "1" }))
14267
+ }))
14268
+ const anywhereAction = (label, fn) => ex(async (req, res) => {
14269
+ const anywhere = this.kernel.anywhere
14270
+ if (!anywhere) {
14271
+ res.status(500).json({ success: false, error: "Pinokio Anywhere is not available" })
14272
+ return
14273
+ }
14274
+ try {
14275
+ await fn(anywhere, req)
14276
+ res.json({ success: true, status: await this.composeAnywhereStatus() })
14277
+ } catch (error) {
14278
+ console.log(`[anywhere] ${label} failed`, error)
14279
+ res.status(500).json({ success: false, error: error && error.message ? error.message : String(error) })
14280
+ }
14281
+ })
14282
+ this.app.post("/anywhere/start", anywhereAction("start", (anywhere) => anywhere.enable()))
14283
+ this.app.post("/anywhere/stop", anywhereAction("stop", (anywhere) => anywhere.disable()))
14284
+ this.app.post("/anywhere/logout", anywhereAction("logout", (anywhere) => anywhere.logout()))
14162
14285
  this.app.get("/network", ex(async (req, res) => {
14163
14286
  let protocol = req.get('X-Forwarded-Proto') || "http"
14164
14287
  let { requirements, install_required, requirements_pending, error } = await this.kernel.bin.check({
@@ -0,0 +1 @@
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"}]}