pinokiod 8.2.33 → 8.2.35

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.
@@ -22,10 +22,8 @@ const crypto = require('crypto')
22
22
  const { spawn, execFile, execFileSync } = require('child_process')
23
23
  const TailscaleCLI = require('./cli')
24
24
  const writeJson = require('./state')
25
- const { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, rewriteHrefForwarded, LOOPBACK_HOSTS } = require('./serve')
25
+ const { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, LOOPBACK_HOSTS } = require('./serve')
26
26
  const { loadPortMap, savePortMap, assignPorts } = require('./ports')
27
- const { Forwarders } = require('./forward')
28
- const net = require('net')
29
27
  const Util = require('../util')
30
28
 
31
29
  const DEFAULT_HOSTNAME = 'pinokio'
@@ -41,10 +39,6 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
41
39
  class Anywhere {
42
40
  constructor(kernel) {
43
41
  this.kernel = kernel
44
- // the daemon's outbound HTTP proxy, picked by pinokiod at each daemon start; null while down
45
- this.proxyPort = null
46
- this.ownedProxyPort = null // the port on an adopted daemon's command line, set by ownedPid
47
- this.forwarders = new Forwarders({ proxyUrl: () => this.proxyUrl(), serverPort: () => this.serverPort() })
48
42
  this.platform = kernel && kernel.platform ? kernel.platform : os.platform()
49
43
  this.child = null
50
44
  this.api = null
@@ -82,9 +76,6 @@ class Anywhere {
82
76
  pidPath() {
83
77
  return this.stateDir('tailscaled.pid')
84
78
  }
85
- proxyUrl() {
86
- return this.proxyPort ? `http://127.0.0.1:${this.proxyPort}` : null
87
- }
88
79
  socketPath() {
89
80
  if (this._socketPath) return this._socketPath
90
81
  if (this.platform === 'win32') {
@@ -107,8 +98,7 @@ class Anywhere {
107
98
  }
108
99
  return null
109
100
  }
110
- // proxyPort: the outbound proxy's loopback port; null describes a daemon from before the proxy existed
111
- daemonArgs(proxyPort = this.proxyPort) {
101
+ daemonArgs() {
112
102
  const args = [
113
103
  '--tun=userspace-networking',
114
104
  '--state', this.stateDir('tailscaled.state'),
@@ -116,7 +106,6 @@ class Anywhere {
116
106
  '--socket', this.socketPath(),
117
107
  '--port', '0',
118
108
  ]
119
- if (proxyPort) args.push(`--outbound-http-proxy-listen=127.0.0.1:${proxyPort}`)
120
109
  if (this.platform === 'win32') {
121
110
  // 1.104+: run as a per-user console process, no service / admin needed
122
111
  args.push('--windows-mode=dev')
@@ -245,17 +234,10 @@ class Anywhere {
245
234
  const alive = await this.daemonAlive()
246
235
  if (this.stopping) return false
247
236
  if (alive && (!(await this.assertOwnership()) || this.stopping)) return false
248
- // a daemon this process started keeps its port; an adopted one gives the port on its command
249
- // line, none for a daemon from before the proxy existed, so forwarding waits for its next start
250
- if (alive) this.proxyPort = this.child ? this.proxyPort : this.ownedProxyPort
251
237
  if (!alive) {
252
238
  const pid = await this.readPid()
253
239
  if (this.stopping) return false
254
240
  if (pid) throw new Error('tailscaled is still running but its private endpoint is unavailable; no second daemon was started.')
255
- // forwarders of a daemon that died relay to its proxy port; a new daemon gets new ones
256
- await this.forwarders.stopAll()
257
- // a free port now; the daemon fails to start if something takes it first, and Try again picks another
258
- this.proxyPort = await freeLoopbackPort()
259
241
  const logFd = fs.openSync(this.logPath(), 'a')
260
242
  const env = Object.assign({}, process.env, {
261
243
  TS_LOGS_DIR: this.stateDir(),
@@ -273,7 +255,6 @@ class Anywhere {
273
255
  if (this.child === child) {
274
256
  // not a stop() of ours: surface it, the page offers "Try again"
275
257
  this.child = null
276
- this.proxyPort = null // its proxy went with it; the next start replaces the forwarders
277
258
  if (!this.stopping && !this.error) this.error = `tailscaled exited (code=${code} signal=${signal})`
278
259
  try { fs.rmSync(this.pidPath(), { force: true }) }
279
260
  catch (error) { this.error = `Could not remove tailscaled PID record: ${error.message}` }
@@ -344,18 +325,12 @@ class Anywhere {
344
325
  }
345
326
  ownedPid(inspection, command) {
346
327
  command = command.trim()
347
- // the proxy port is whatever the running daemon was given; a daemon from before the
348
- // proxy existed has none and is still ours. A match records the port for adoption.
349
- const match = command.match(/--outbound-http-proxy-listen=127\.0\.0\.1:(\d+)/i)
350
- const port = match ? Number(match[1]) : null
351
- let expected = [this.binPath(), ...this.daemonArgs(port)].join(' ')
328
+ let expected = [this.binPath(), ...this.daemonArgs()].join(' ')
352
329
  if (this.platform === 'win32') {
353
330
  command = command.replace(/"/g, '').toLowerCase()
354
331
  expected = expected.toLowerCase()
355
332
  }
356
- if (command !== expected) return null
357
- this.ownedProxyPort = port
358
- return inspection.pid
333
+ return command === expected ? inspection.pid : null
359
334
  }
360
335
  async readPid() {
361
336
  const inspection = this.pidInspection()
@@ -397,8 +372,6 @@ class Anywhere {
397
372
  this.child = null
398
373
  this.login = null
399
374
  this.api = null
400
- this.proxyPort = null
401
- await this.forwarders.stopAll()
402
375
  fs.rmSync(this.pidPath(), { force: true })
403
376
  this.lastStatus = null
404
377
  this.lastStatusAt = 0
@@ -545,7 +518,6 @@ class Anywhere {
545
518
  const generic = !hostName || /^(localhost|iphone|ipad|android)$/i.test(hostName)
546
519
  peers.push({
547
520
  name: (generic && dnsLabel) ? dnsLabel : (hostName || dnsLabel || key),
548
- dnsName: p.DNSName ? String(p.DNSName).replace(/\.$/, '') : null,
549
521
  os: osName,
550
522
  mobile: MOBILE_OS.has(osName.toLowerCase()),
551
523
  online: !!p.Online,
@@ -641,24 +613,15 @@ class Anywhere {
641
613
  if (!dnsName || !this.config || !this.config.enabled) return false
642
614
  return String(host || '').toLowerCase().replace(/:443$/, '') === dnsName.toLowerCase()
643
615
  }
644
- // A peer from `status --json` by the first label of its DNS name, the key
645
- // of the peer routes; null for a name that is not a peer.
646
- peerFor(label) {
647
- if (!this.lastStatus || typeof label !== 'string') return null
648
- const wanted = label.toLowerCase()
649
- return this.peersFrom(this.lastStatus).find((p) => p.dnsName && p.dnsName.split('.')[0].toLowerCase() === wanted) || null
650
- }
651
616
  // Rewrites one menu link for a visitor arriving through the Tailscale
652
617
  // address. Only a loopback link with a port is touched, and only when the
653
618
  // request's Host header is this node's name; any other request gets the
654
- // href back unchanged. A visitor forwarded by another Pinokio gets the
655
- // relative path its forwarder answers instead of this node's address.
656
- rewriteHref(host, href, forwarded = false) {
619
+ // href back unchanged.
620
+ rewriteHref(host, href) {
657
621
  if (!this.isNodeHost(host)) return href
658
- const routes = describeServeConfig(this.lastServe, this.dnsNameFrom(this.lastStatus))
659
- return forwarded ? rewriteHrefForwarded(href, routes) : rewriteHref(href, routes)
622
+ return rewriteHref(href, describeServeConfig(this.lastServe, this.dnsNameFrom(this.lastStatus)))
660
623
  }
661
- async rewriteMenu(host, menu, forwarded = false) {
624
+ async rewriteMenu(host, menu) {
662
625
  if (!Array.isArray(menu) || !this.isNodeHost(host)) return menu
663
626
  // Confirm new or changed routes on first render; refresh checks unchanged ones.
664
627
  const apps = this.appTargets()
@@ -671,7 +634,7 @@ class Anywhere {
671
634
  for (const item of items) {
672
635
  if (!item || typeof item !== 'object') continue
673
636
  if (typeof item.href === 'string') {
674
- const next = this.rewriteHref(host, item.href, forwarded)
637
+ const next = this.rewriteHref(host, item.href)
675
638
  if (next !== item.href) {
676
639
  if (item.src === item.href) item.src = next
677
640
  if (item.target_full === item.href) item.target_full = next
@@ -779,7 +742,7 @@ class Anywhere {
779
742
  const dnsName = this.dnsNameFrom(status)
780
743
  const address = dnsName ? `https://${dnsName}` : null
781
744
  const account = this.accountFrom(status)
782
- const peers = this.peersFrom(status).map(({ dnsName, ...peer }) => peer) // DNS names serve the peer routes, not the payload
745
+ const peers = this.peersFrom(status)
783
746
  const out = Object.assign(base, {
784
747
  running: true,
785
748
  version: status.Version,
@@ -846,16 +809,4 @@ class Anywhere {
846
809
  }
847
810
 
848
811
 
849
- // A loopback port nothing is listening on right now, for the daemon's proxy.
850
- function freeLoopbackPort() {
851
- return new Promise((resolve, reject) => {
852
- const probe = net.createServer()
853
- probe.once('error', reject)
854
- probe.listen(0, '127.0.0.1', () => {
855
- const { port } = probe.address()
856
- probe.close(() => resolve(port))
857
- })
858
- })
859
- }
860
-
861
812
  module.exports = Anywhere
@@ -94,28 +94,12 @@ function sameServeConfig(a, b) {
94
94
  return isDeepStrictEqual(a || null, b || null)
95
95
  }
96
96
 
97
- // the path every forwarder answers itself (spec/requirements/pinokio-anywhere-api.md, Peers)
98
- const OPEN_PATH = '/.pinokio/open'
99
-
100
- // A loopback link with a port that a Serve route carries: its route and the
101
- // link's path, query and fragment; null for any other link.
102
- function routedLink(href, routes) {
97
+ function rewriteHref(href, routes) {
103
98
  let url
104
- try { url = new URL(String(href)) } catch (_) { return null }
105
- if (!/^https?:$/.test(url.protocol) || !LOOPBACK_HOSTS.has(url.hostname) || !url.port) return null
99
+ try { url = new URL(String(href)) } catch (_) { return href }
100
+ if (!/^https?:$/.test(url.protocol) || !LOOPBACK_HOSTS.has(url.hostname) || !url.port) return href
106
101
  const route = routes.find((entry) => entry.local === Number(url.port))
107
- return route ? { route, path: url.pathname + url.search + url.hash } : null
108
- }
109
- function rewriteHref(href, routes) {
110
- const link = routedLink(href, routes)
111
- return link ? link.route.url + link.path : href
112
- }
113
- // The same link for a visitor arriving through another Pinokio's forwarder: the
114
- // published port and the path on the relative path the forwarder answers, so
115
- // the caller maps the port on demand.
116
- function rewriteHrefForwarded(href, routes) {
117
- const link = routedLink(href, routes)
118
- return link ? `${OPEN_PATH}?port=${link.route.port}&to=${encodeURIComponent(link.path)}` : href
102
+ return route ? route.url + url.pathname + url.search + url.hash : href
119
103
  }
120
104
 
121
- module.exports = { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, rewriteHrefForwarded, LOOPBACK_HOSTS, DEFAULT_HTTPS_PORT, OPEN_PATH }
105
+ module.exports = { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, LOOPBACK_HOSTS, DEFAULT_HTTPS_PORT }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.2.33",
3
+ "version": "8.2.35",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -35,8 +35,6 @@ const TerminalApi = require('../kernel/api/terminal')
35
35
  const PluginSources = require("../kernel/plugin_sources")
36
36
  const ManagedSkills = require("../kernel/managed_skills")
37
37
  const { rewriteHref: rewriteAnywhereHref, LOOPBACK_HOSTS: ANYWHERE_LOOPBACK_HOSTS } = require("../kernel/anywhere/serve")
38
- const { openPath: anywhereOpenPath, FORWARDED_HEADER: ANYWHERE_FORWARDED_HEADER } = require("../kernel/anywhere/forward")
39
- const { FIRST_PORT: ANYWHERE_FIRST_PORT, LAST_PORT: ANYWHERE_LAST_PORT } = require("../kernel/anywhere/ports")
40
38
  const { menuTargets: launcherMenuTargets, isWebUrl } = require("../kernel/api/launcher_target")
41
39
  const { isPathRemovalBlockedError, removePath } = require("../kernel/path_removal")
42
40
 
@@ -1051,122 +1049,6 @@ class Server {
1051
1049
  const dnsName = anywhere && typeof anywhere.dnsNameFrom === "function" ? anywhere.dnsNameFrom(anywhere.lastStatus) : null
1052
1050
  return !!dnsName && parsed.protocol === "https:" && host === dnsName.toLowerCase()
1053
1051
  }
1054
- // a Host header on this computer itself (localhost, 127.0.0.1, [::1], with or without a port)
1055
- isLoopbackHost(host) {
1056
- const name = String(host || "").toLowerCase().replace(/:\d+$/, "")
1057
- return name === "localhost" || name === "127.0.0.1" || name === "[::1]"
1058
- }
1059
- // A request from a browser on this computer, as opposed to one that came through Tailscale
1060
- // Serve or another Pinokio: those also arrive on loopback, and a tailnet client chooses its
1061
- // own Host, but it cannot strip the headers Serve adds on the way or the peer marker.
1062
- isLocalRequest(req) {
1063
- const socket = req.socket && req.socket.remoteAddress
1064
- const loopback = socket === "127.0.0.1" || socket === "::1" || socket === "::ffff:127.0.0.1"
1065
- return loopback && this.isLoopbackHost(req.get("host")) && !req.get("X-Forwarded-For") && !req.get("X-Pinokio-Peer")
1066
- }
1067
- // the peer routes act on this computer's behalf: served on this computer only, to its own
1068
- // page only. Their redirects end on 127.0.0.1, which mean nothing anywhere else.
1069
- anywherePeerAllowed(req) {
1070
- if (!this.isLocalRequest(req)) return false
1071
- const origin = req.get("Origin")
1072
- if (!origin || origin === "https://pinokio.co") return true
1073
- try { return new URL(origin).host.toLowerCase() === String(req.get("host")).toLowerCase() } catch (_) { return false }
1074
- }
1075
- // the reason the peer routes cannot work right now, or null
1076
- anywherePeerUnavailable() {
1077
- const anywhere = this.kernel.anywhere
1078
- if (anywhere.proxyUrl()) return null
1079
- return anywhere.lastStatus && !anywhere.error ? "Turn Remote off and on to reach your other Pinokios" : "Remote is not running on this computer"
1080
- }
1081
- // Remote from computer to computer (spec/requirements/pinokio-anywhere-api.md, Peers):
1082
- // the other Pinokios on the tailnet, found by asking each online computer for its status
1083
- // through the daemon's proxy, their running apps linked through this node's forwarders.
1084
- async composeAnywherePeers() {
1085
- const anywhere = this.kernel.anywhere
1086
- const unavailable = this.anywherePeerUnavailable()
1087
- if (unavailable) return { status: 502, body: { error: unavailable } }
1088
- const candidates = anywhere.peersFrom(anywhere.lastStatus).filter((peer) => peer.online && !peer.mobile && peer.dnsName)
1089
- const probes = await Promise.allSettled(candidates.map(async (peer) => {
1090
- const answer = await anywhere.forwarders.request(`https://${peer.dnsName}/anywhere/status`, { timeoutMs: 4000 })
1091
- if (answer.status !== 200) return null
1092
- let status
1093
- try { status = JSON.parse(answer.body) } catch (_) { return null }
1094
- if (!status || typeof status.phase !== "string") return null
1095
- const label = peer.dnsName.split(".")[0]
1096
- const apps = Array.isArray(status.apps) ? status.apps : []
1097
- return {
1098
- name: label,
1099
- address: `https://${peer.dnsName}`,
1100
- apps: apps.map((app) => {
1101
- const { remote, ...rest } = app
1102
- return Object.assign(rest, { open: this.anywherePeerOpenPath(label, remote) })
1103
- }),
1104
- }
1105
- }))
1106
- return { status: 200, body: { peers: probes.map((probe) => probe.status === "fulfilled" ? probe.value : null).filter(Boolean) } }
1107
- }
1108
- // this node's open route for a peer's published address, or null for no address
1109
- anywherePeerOpenPath(label, remote) {
1110
- if (typeof remote !== "string") return null
1111
- let url
1112
- try { url = new URL(remote) } catch (_) { return null }
1113
- return anywhereOpenPath(label, url.port ? Number(url.port) : 443, url.pathname + url.search + url.hash)
1114
- }
1115
- // GET /anywhere/peers/<name>/open?port=<n>&to=<path>: { status, headers } or { status, body }
1116
- async anywherePeerOpen(name, query = {}) {
1117
- const anywhere = this.kernel.anywhere
1118
- const peer = anywhere.peerFor(name)
1119
- if (!peer) return { status: 400, body: { error: `Not a computer on your network: ${name}` } }
1120
- const port = Number(query.port)
1121
- if (!(port === 443 || (Number.isInteger(port) && port >= ANYWHERE_FIRST_PORT && port <= ANYWHERE_LAST_PORT))) {
1122
- return { status: 400, body: { error: `Not a Remote port: ${query.port}` } }
1123
- }
1124
- const unavailable = this.anywherePeerUnavailable()
1125
- if (unavailable) return { status: 502, body: { error: unavailable } }
1126
- let local
1127
- try {
1128
- local = await anywhere.forwarders.open(peer.dnsName, port)
1129
- } catch (error) {
1130
- return { status: 500, body: { error: error && error.message ? error.message : String(error) } }
1131
- }
1132
- // `to` resolved against the forwarder: encoded for the header, and never another origin
1133
- const base = `http://127.0.0.1:${local}/`
1134
- let location
1135
- try { location = new URL(typeof query.to === "string" ? query.to : "/", base) } catch (_) { location = new URL(base) }
1136
- if (location.origin !== new URL(base).origin) location = new URL(base)
1137
- return { status: 302, headers: { Location: location.href, "Cache-Control": "no-store" } }
1138
- }
1139
- // /anywhere/peers/<name>/<path>: the peer's own API through the daemon's proxy (API spec, Relays).
1140
- // JSON routes get the peer's published addresses turned into this node's open routes, absolute,
1141
- // since the client may be pinokio.co on another origin; an asset is passed through as it is.
1142
- async relayAnywherePeer(name, method, relayPath, body = {}) {
1143
- const anywhere = this.kernel.anywhere
1144
- const peer = anywhere.peerFor(name)
1145
- if (!peer) return { status: 400, body: { error: `Not a computer on your network: ${name}` } }
1146
- const unavailable = this.anywherePeerUnavailable()
1147
- if (unavailable) return { status: 502, body: { error: unavailable } }
1148
- const json = relayPath.startsWith("anywhere/")
1149
- let answer
1150
- try {
1151
- answer = await anywhere.forwarders.request(`https://${peer.dnsName}/${relayPath}`, method === "POST"
1152
- ? { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}), timeoutMs: 30000 }
1153
- : { method, timeoutMs: 8000 })
1154
- } catch (error) {
1155
- return { status: 502, body: { error: `Could not reach ${peer.name}: ${error && error.message ? error.message : error}` } }
1156
- }
1157
- if (!json) return { status: answer.status, raw: answer.raw, contentType: answer.headers["content-type"] || "application/octet-stream" }
1158
- let parsed
1159
- try { parsed = JSON.parse(answer.body) } catch (_) {
1160
- return { status: 502, body: { error: `${peer.name} did not answer with JSON (HTTP ${answer.status})` } }
1161
- }
1162
- const own = (url) => { try { return new URL(url).hostname.toLowerCase() === peer.dnsName.toLowerCase() } catch (_) { return false } }
1163
- const route = (url) => `http://127.0.0.1:${anywhere.serverPort()}${this.anywherePeerOpenPath(peer.dnsName.split(".")[0], url)}`
1164
- if (parsed && Array.isArray(parsed.apps)) {
1165
- for (const app of parsed.apps) if (app && typeof app.remote === "string" && own(app.remote)) app.remote = route(app.remote)
1166
- }
1167
- if (parsed && parsed.action === "open" && typeof parsed.url === "string" && own(parsed.url)) parsed.url = route(parsed.url)
1168
- return { status: answer.status, body: parsed }
1169
- }
1170
1052
  // the app's menu as launch targets: scripts and web addresses, never interface links;
1171
1053
  // text is plain: a launcher may write HTML, which the desktop renders and the phone does not
1172
1054
  async anywhereMenu(api, repoPath) {
@@ -5194,8 +5076,7 @@ class Server {
5194
5076
  // published app's fixed https address; any other host is left for the
5195
5077
  // Home Server rewrite below, exactly as before.
5196
5078
  if (this.kernel.anywhere) {
5197
- // a visitor forwarded by another Pinokio gets links its forwarder answers (API spec, Peers)
5198
- await this.kernel.anywhere.rewriteMenu(req.$source && req.$source.host, config.menu, req.get(ANYWHERE_FORWARDED_HEADER) === "1")
5079
+ await this.kernel.anywhere.rewriteMenu(req.$source && req.$source.host, config.menu)
5199
5080
  }
5200
5081
  config = Util.rewrite_localhost(this.kernel, config, req.$source)
5201
5082
  return config
@@ -7007,12 +6888,6 @@ class Server {
7007
6888
  this.kernel.vault.automaticScans.supported)
7008
6889
  next()
7009
6890
  })
7010
- // pinokio.co's Remote tab reaches the peer routes from a browser on this computer; Chrome's
7011
- // local network access wants this on the preflight, which the cors middleware below answers
7012
- this.app.use("/anywhere/peers", (req, res, next) => {
7013
- if (req.get("Access-Control-Request-Private-Network")) res.set("Access-Control-Allow-Private-Network", "true")
7014
- next()
7015
- })
7016
6891
  this.app.use(cors({
7017
6892
  origin: '*'
7018
6893
  }));
@@ -14485,8 +14360,6 @@ class Server {
14485
14360
  status,
14486
14361
  theme: this.theme,
14487
14362
  agent: req.agent,
14488
- // Your other Pinokios links end on 127.0.0.1, which mean nothing to a phone reading this page
14489
- loopback: this.isLocalRequest(req),
14490
14363
  })
14491
14364
  }))
14492
14365
  this.app.get("/anywhere/status", ex(async (req, res) => {
@@ -14513,32 +14386,6 @@ class Server {
14513
14386
  })
14514
14387
  this.app.post("/anywhere/apps/launch", anywhereAppAction((body) => this.composeAnywhereLaunch(body)))
14515
14388
  this.app.post("/anywhere/apps/stop", anywhereAppAction((body) => this.composeAnywhereStop(body)))
14516
- // the other Pinokios on the tailnet, for this computer's own Remote page (API spec, Peers)
14517
- const anywherePeerRoute = (fn) => ex(async (req, res) => {
14518
- res.set("Cache-Control", "no-store")
14519
- if (!this.anywherePeerAllowed(req)) {
14520
- res.status(403).json({ error: "Allowed only on this computer" })
14521
- return
14522
- }
14523
- try {
14524
- const answer = await fn(req)
14525
- if (answer.headers) res.set(answer.headers)
14526
- if (answer.raw) res.status(answer.status).type(answer.contentType).send(answer.raw)
14527
- else if (answer.body === undefined) res.status(answer.status).end()
14528
- else res.status(answer.status).json(answer.body)
14529
- } catch (error) {
14530
- res.status(500).json({ error: error && error.message ? error.message : String(error) })
14531
- }
14532
- })
14533
- this.app.get("/anywhere/peers", anywherePeerRoute(() => this.composeAnywherePeers()))
14534
- this.app.get("/anywhere/peers/:name/open", anywherePeerRoute((req) => this.anywherePeerOpen(req.params.name, req.query)))
14535
- // the peer's own API under its prefix: a client swaps the peer's address for this prefix and changes nothing else
14536
- const anywhereRelay = (method, relayPath) => anywherePeerRoute((req) => this.relayAnywherePeer(req.params.name, method, relayPath(req), req.body))
14537
- this.app.get("/anywhere/peers/:name/anywhere/status", anywhereRelay("GET", () => "anywhere/status"))
14538
- this.app.get("/anywhere/peers/:name/anywhere/apps", anywhereRelay("GET", () => "anywhere/apps"))
14539
- this.app.post("/anywhere/peers/:name/anywhere/apps/launch", anywhereRelay("POST", () => "anywhere/apps/launch"))
14540
- this.app.post("/anywhere/peers/:name/anywhere/apps/stop", anywhereRelay("POST", () => "anywhere/apps/stop"))
14541
- this.app.get("/anywhere/peers/:name/asset/*", anywhereRelay("GET", (req) => "asset/" + encodeURI(req.params[0])))
14542
14389
  const anywhereAction = (label, fn) => ex(async (req, res) => {
14543
14390
  const anywhere = this.kernel.anywhere
14544
14391
  if (!anywhere) {
@@ -38,31 +38,15 @@ body.dark .anywhere-card { border-color: rgba(255,255,255,0.1); background: rgba
38
38
  .anywhere-card h3 { margin: 18px 0 6px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.7; }
39
39
  .anywhere-card p { margin: 6px 0; line-height: 1.5; }
40
40
  .anywhere-muted { opacity: 0.7; font-size: 13px; }
41
- .anywhere-grid { display: grid; grid-template-columns: minmax(280px, 380px) 1fr; gap: 16px; align-items: start; }
42
- @media (max-width: 900px) { .anywhere-grid { grid-template-columns: 1fr; } }
43
- .anywhere-signout, .anywhere-linkbtn { background: none; border: none; padding: 0; font: inherit; color: inherit; text-decoration: underline; cursor: pointer; }
44
- /* which device is connecting: a segmented control, the phone panel by default */
45
- .anywhere-segments { display: grid; grid-template-columns: 1fr 1fr; width: 100%; padding: 4px; border-radius: 12px; background: rgba(0,0,0,0.05); }
46
- body.dark .anywhere-segments { background: rgba(255,255,255,0.08); }
47
- .anywhere-segments label { position: relative; display: block; }
48
- .anywhere-segments input { position: absolute; opacity: 0; width: 0; height: 0; }
49
- .anywhere-segments span { display: block; text-align: center; padding: 9px 12px; border-radius: 9px; font-size: 14px; cursor: pointer; opacity: 0.7; transition: opacity 0.15s; }
50
- .anywhere-segments span:hover { opacity: 1; }
51
- .anywhere-segments input:checked + span { background: white; font-weight: bold; opacity: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.12); }
52
- body.dark .anywhere-segments input:checked + span { background: rgba(255,255,255,0.16); box-shadow: none; }
53
- .anywhere-segments input:focus-visible + span { outline: 2px solid currentColor; outline-offset: 1px; }
54
- #anywhere-connect[data-device=phone] [data-panel=computer], #anywhere-connect[data-device=computer] [data-panel=phone] { display: none; }
55
- #anywhere-connect [data-panel] { display: flex; flex-direction: column; align-items: center; gap: 14px; width: 100%; }
56
- #anywhere-connect [data-panel] p { margin: 0; max-width: 360px; font-size: 15px; line-height: 1.5; }
57
- #anywhere-connect [data-panel=computer] { padding-top: 10px; }
58
- .anywhere-warning { padding: 10px 14px; border-radius: 8px; background: rgba(255,140,0,0.14); font-size: 13px; line-height: 1.5; margin: 16px 0 0; }
59
- .anywhere-warning a { color: inherit; }
60
- .anywhere-connect-card { max-width: 440px; padding: 22px 24px 24px; }
61
- .anywhere-connect-title { text-align: center; font-size: 17px; margin: 0 0 14px; }
62
- .anywhere-qr { display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; }
63
- .anywhere-qr img { width: 212px; height: 212px; border-radius: 14px; background: white; padding: 12px; border: 1px solid rgba(0,0,0,0.08); margin-top: 4px; }
64
- body.dark .anywhere-qr img { border-color: transparent; }
65
- .anywhere-qr > .anywhere-muted { font-size: 12px; margin-top: 2px; }
41
+ .anywhere-connect { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: stretch; }
42
+ @media (max-width: 900px) { .anywhere-connect { grid-template-columns: 1fr; } }
43
+ .anywhere-device-card { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; gap: 12px; text-align: center; padding: 22px 24px 24px; }
44
+ .anywhere-device-icon { font-size: 32px; line-height: 1; opacity: 0.7; margin-bottom: -2px; }
45
+ .anywhere-device-card h2 { font-size: 17px; margin: 0; }
46
+ .anywhere-device-card p { margin: 0; max-width: 360px; font-size: 15px; line-height: 1.5; }
47
+ .anywhere-device-card img { width: 212px; height: 212px; border-radius: 14px; background: white; padding: 12px; border: 1px solid rgba(0,0,0,0.08); }
48
+ body.dark .anywhere-device-card img { border-color: transparent; }
49
+ .anywhere-account-line { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin: 14px 0 18px; }
66
50
  .anywhere-address {
67
51
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
68
52
  font-size: 15px; word-break: break-all;
@@ -225,31 +209,29 @@ document.addEventListener('DOMContentLoaded', function() {
225
209
 
226
210
  <div class='anywhere-state' data-state='ready'>
227
211
  <div class='anywhere-warning' id='anywhere-expiry-warning' hidden></div>
228
- <div class='anywhere-grid'>
229
- <div class='anywhere-card anywhere-connect-card'>
230
- <h2 class='anywhere-connect-title'>Connect a device</h2>
231
- <div class='anywhere-qr' id='anywhere-connect' data-device='phone'>
232
- <div class='anywhere-segments'>
233
- <label><input type='radio' name='anywhere-device' value='phone' checked><span>Phone or tablet</span></label>
234
- <label><input type='radio' name='anywhere-device' value='computer'><span>Another computer</span></label>
235
- </div>
236
- <div data-panel='phone'>
237
- <img id='anywhere-qr' alt='QR code to connect' src=''>
238
- <p>Scan with your phone. It walks you through the rest.</p>
239
- </div>
240
- <div data-panel='computer'>
241
- <p>Open this link on the other computer. It walks you through installing Tailscale and signing in as <b class='anywhere-account-inline'></b>, then opens this Pinokio.</p>
242
- <div class='anywhere-address'><a id='anywhere-address' href='#' target='_blank' rel='noopener'></a></div>
243
- <p><button type='button' class='anywhere-btn secondary anywhere-copy' id='anywhere-copy' disabled><i class='fa-regular fa-copy' aria-hidden='true'></i> Copy link</button></p>
244
- <div id='anywhere-copy-status' class='anywhere-muted' role='status' aria-live='polite'></div>
245
- <div id='anywhere-copy-manual' class='anywhere-copy-manual' hidden>
246
- <input id='anywhere-copy-value' type='text' readonly aria-label='Remote link' aria-describedby='anywhere-copy-status'>
247
- </div>
248
- </div>
249
- <div class='anywhere-muted'>signed in as <b id='anywhere-account'></b> · <button type='button' class='anywhere-signout' data-action='logout'>Sign out</button></div>
250
- <div class='anywhere-waiting' id='anywhere-waiting'><i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for another device to join…</div>
212
+ <div class='anywhere-connect' id='anywhere-connect'>
213
+ <div class='anywhere-card anywhere-device-card'>
214
+ <i class='fa-solid fa-mobile-screen anywhere-device-icon' aria-hidden='true'></i>
215
+ <h2>Connect a phone or tablet</h2>
216
+ <p>Scan with your phone. It walks you through the rest.</p>
217
+ <img id='anywhere-qr' alt='QR code to connect' src=''>
218
+ </div>
219
+ <div class='anywhere-card anywhere-device-card'>
220
+ <i class='fa-solid fa-laptop anywhere-device-icon' aria-hidden='true'></i>
221
+ <h2>Connect another computer</h2>
222
+ <p>Open this link on the other computer. It walks you through installing Tailscale and signing in as <b class='anywhere-account-inline'></b>, then opens this Pinokio.</p>
223
+ <div class='anywhere-address'><a id='anywhere-address' href='#' target='_blank' rel='noopener'></a></div>
224
+ <p><button type='button' class='anywhere-btn secondary anywhere-copy' id='anywhere-copy' disabled><i class='fa-regular fa-copy' aria-hidden='true'></i> Copy link</button></p>
225
+ <div id='anywhere-copy-status' class='anywhere-muted' role='status' aria-live='polite'></div>
226
+ <div id='anywhere-copy-manual' class='anywhere-copy-manual' hidden>
227
+ <input id='anywhere-copy-value' type='text' readonly aria-label='Remote link' aria-describedby='anywhere-copy-status'>
251
228
  </div>
252
229
  </div>
230
+ </div>
231
+ <div class='anywhere-account-line'>
232
+ <span class='anywhere-muted'>signed in as <b id='anywhere-account'></b> · <button type='button' class='anywhere-signout' data-action='logout'>Sign out</button></span>
233
+ <div class='anywhere-waiting' id='anywhere-waiting'><i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for another device to join…</div>
234
+ </div>
253
235
  <div id='anywhere-details' hidden>
254
236
  <div class='anywhere-card'>
255
237
  <h2>Running apps</h2>
@@ -258,13 +240,6 @@ document.addEventListener('DOMContentLoaded', function() {
258
240
  <h3>Published addresses</h3>
259
241
  <div id='anywhere-ports' class='anywhere-muted'></div>
260
242
  </div>
261
- <% if (locals.loopback) { %>
262
- <div class='anywhere-card'>
263
- <h2>Your other Pinokios</h2>
264
- <p class='anywhere-muted'>Computers on your network with Remote on. They open through this Pinokio; nothing else to install.</p>
265
- <div id='anywhere-others'><p class='anywhere-muted'>Looking…</p></div>
266
- </div>
267
- <% } %>
268
243
  <div class='anywhere-card'>
269
244
  <h2>Devices on your network</h2>
270
245
  <div id='anywhere-peers'></div>
@@ -282,7 +257,6 @@ document.addEventListener('DOMContentLoaded', function() {
282
257
  </p>
283
258
  </div>
284
259
  </div>
285
- </div>
286
260
  </div>
287
261
 
288
262
  </div>
@@ -477,56 +451,6 @@ document.addEventListener('DOMContentLoaded', function() {
477
451
  }
478
452
  }
479
453
 
480
- // Your other Pinokios: only on a loopback host, refreshed with each status poll while ready
481
- const others = document.getElementById('anywhere-others')
482
- let othersBusy = false
483
- let othersNote = '' // the last failed action, shown until the next one
484
- const renderOthers = (peers) => {
485
- if (!others) return
486
- others.innerHTML = (othersNote ? `<p class='anywhere-error'>${esc(othersNote)}</p>` : '') + (peers.length === 0
487
- ? `<p class='anywhere-muted'>No other Pinokio with Remote on is online.</p>`
488
- : peers.map((peer) => `
489
- <div class='anywhere-row anywhere-other' data-peer='${esc(peer.name)}'>
490
- <i class='fa-solid fa-desktop'></i>
491
- <div class='grow'>
492
- <div class='title'>${esc(peer.name)} <a class='remote' href='/anywhere/peers/${encodeURIComponent(peer.name)}/open?port=443&to=%2F' target='_blank' rel='noopener'>Open Pinokio</a></div>
493
- ${(peer.apps || []).length === 0 ? `<div class='sub'>no app running</div>` : peer.apps.map((app) => `
494
- <div class='sub'>${esc(app.name)} ${app.open ? `<a class='remote' href='${esc(app.open)}' target='_blank' rel='noopener'>Open</a>` : `<span class='anywhere-badge'>${esc(app.state || 'starting')}</span>`} · <button type='button' class='anywhere-linkbtn' data-stop='${esc(app.id)}'>Stop</button></div>`).join('')}
495
- </div>
496
- </div>`).join(''))
497
- }
498
- const refreshOthers = async () => {
499
- if (!others || othersBusy || current.phase !== 'ready') return
500
- othersBusy = true
501
- try {
502
- const response = await fetch('/anywhere/peers', { cache: 'no-store' })
503
- const answer = await response.json().catch(() => ({}))
504
- if (!response.ok) throw new Error(answer.error || `HTTP ${response.status}`)
505
- renderOthers(answer.peers || [])
506
- } catch (e) {
507
- others.innerHTML = `<p class='anywhere-muted'>Could not look for other Pinokios: ${esc(e.message)}</p>`
508
- } finally {
509
- othersBusy = false
510
- }
511
- }
512
- if (others) {
513
- others.addEventListener('click', async (event) => {
514
- const button = event.target.closest('button[data-stop]')
515
- if (!button) return
516
- const peer = button.closest('[data-peer]').dataset.peer
517
- button.disabled = true
518
- othersNote = ''
519
- try {
520
- const response = await fetch(`/anywhere/peers/${encodeURIComponent(peer)}/anywhere/apps/stop`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: button.dataset.stop }) })
521
- const answer = await response.json().catch(() => ({}))
522
- if (!response.ok) othersNote = `Could not stop it on ${peer}: ${answer.error || `HTTP ${response.status}`}`
523
- } catch (e) {
524
- othersNote = `Could not stop it on ${peer}: ${e.message}`
525
- }
526
- await refreshOthers()
527
- })
528
- }
529
-
530
454
  const poll = async () => {
531
455
  try {
532
456
  const response = await fetch('/anywhere/status', { cache: 'no-store' })
@@ -535,7 +459,6 @@ document.addEventListener('DOMContentLoaded', function() {
535
459
  } catch (e) {
536
460
  render(Object.assign({}, current, { phase: 'error', error: e.message }))
537
461
  }
538
- refreshOthers()
539
462
  const phase = current.phase || ''
540
463
  const delay = (phase === 'ready' || phase === 'off') ? 5000 : 2000
541
464
  setTimeout(poll, delay)
@@ -583,9 +506,6 @@ document.addEventListener('DOMContentLoaded', function() {
583
506
  openExternal(link.href)
584
507
  }
585
508
  })
586
- for (const input of document.querySelectorAll('input[name=anywhere-device]')) {
587
- input.addEventListener('change', () => { document.getElementById('anywhere-connect').dataset.device = input.value })
588
- }
589
509
  switchEl.addEventListener('click', () => {
590
510
  if (switchEl.dataset.on === 'true') {
591
511
  act('stop')
@@ -595,7 +515,6 @@ document.addEventListener('DOMContentLoaded', function() {
595
515
  })
596
516
 
597
517
  render(current)
598
- refreshOthers()
599
518
  setTimeout(poll, 1500)
600
519
  })()
601
520
  </script>