pinokiod 7.0.0 → 7.0.2

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/shells.js CHANGED
@@ -254,6 +254,7 @@ class Shells {
254
254
  let liveEventAnsiCarry = ""
255
255
 
256
256
  // Keep cross-chunk event matching, but normalize terminal styling away first.
257
+ // Preserve line boundaries so later shell banners cannot fuse onto prior matches.
257
258
  const findLiveEventCarryIndex = (value = "") => {
258
259
  if (!value) {
259
260
  return value.length
@@ -319,7 +320,9 @@ class Shells {
319
320
  if (combined.length === 0) {
320
321
  return ""
321
322
  }
322
- return sh.stripAnsi(combined).replaceAll(/[\r\n]/g, "")
323
+ return sh.stripAnsi(combined)
324
+ .replaceAll(/\r\n/g, "\n")
325
+ .replaceAll(/\r/g, "\n")
323
326
  }
324
327
 
325
328
  // if error doesn't exist, add default "error:" event
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "7.0.0",
3
+ "version": "7.0.2",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -16,43 +16,33 @@ Assume `pterm` is preinstalled and up to date.
16
16
 
17
17
  If running outside Pinokio's own shell, do not assume `pterm` is on `PATH`.
18
18
 
19
- 1. If the client can execute shell commands, first check whether `pterm` already works by name in the current shell.
20
- 2. If `pterm` works by name, use that command form for all `pterm` commands in this skill.
21
- 3. If `pterm` does not work by name or shell command execution is unavailable, resolve `pterm` via `GET http://127.0.0.1:42000/pinokio/path/pterm`.
22
- 4. Read `path` from the response.
23
- 5. Normalize the resolved path for the current platform and shell before executing it.
24
- - On Windows, if the resolved path has no executable Windows extension, prefer a sibling shim such as `.cmd` or `.ps1` when present.
25
- 6. Use the working command/path form consistently for all `pterm` commands in this skill.
26
- 7. If lookup fails, do not immediately conclude that Pinokio is not running.
27
- 8. Distinguish failure modes before stopping:
28
- - `EPERM` / `EACCES` / sandbox denial to `127.0.0.1:42000`:
29
- - treat this as a local permission problem, not a missing runtime
30
- - ask for permission first if the client supports permission prompts, escalation, or tool approval
31
- - rerun the same probe after permission is granted
32
- - if permission prompts are unavailable, report that loopback access is blocked by the client/sandbox
33
- - timeout / connection refused / DNS failure:
34
- - report that the control plane is unreachable rather than claiming `pterm` is uninstalled
35
- - HTTP success with missing/empty `path`:
36
- - continue with fallback path checks below before concluding `pterm` is unavailable
37
- 9. If the control-plane probe is blocked or returns an empty path, check common local `pterm` locations:
38
- - `which pterm` or `where pterm`
39
- 10. If a local `pterm` binary exists, normalize it for the current shell/platform, use it, and continue. If later `pterm` commands fail against `127.0.0.1:42000` with permission errors, report a loopback permission issue explicitly.
40
- 11. Only report "`pterm` unavailable" when both the command-form probe and the resolved/fallback path checks fail.
19
+ If `pterm` is not already executable, use the first executable match from these sources:
20
+
21
+ - Pinokio-managed path from `~/.pinokio/config.json` file's `home` attribute:
22
+ macOS/Linux: `<home>/bin/npm/bin/pterm`
23
+ Windows: `<home>\\bin\\npm\\pterm`
24
+ Optional fallback: `<home>/bin/pterm`
25
+
26
+ - Control-plane path lookup:
27
+ `GET http://127.0.0.1:42000/pinokio/path/pterm`
28
+ If loopback is unreachable and `access` exists in `~/.pinokio/config.json`, retry the same request against `<protocol>://<host>:<port>`.
29
+
30
+ - Generic local lookup:
31
+ `which pterm` / `where pterm`
32
+
33
+ Normalize whichever path you resolve before use.
34
+ - On Windows, if the resolved path has no executable Windows extension, prefer a sibling `.cmd` or `.ps1`.
35
+
36
+ Failure handling:
37
+ - `EPERM` / `EACCES` / sandbox denial: treat as a client permission problem, ask for permission first when possible, and rerun the same probe or `pterm` command after permission is granted.
38
+ - timeout / connection refused / DNS failure: report that the Pinokio control plane is unreachable rather than claiming `pterm` is uninstalled.
39
+ - Only report "`pterm` unavailable" when the config/home-derived path, control-plane path resolution, and local path checks all fail.
41
40
 
42
41
  Use direct `pterm` commands for control-plane operations:
43
42
 
44
- 1. `pterm search "<query>"`
45
- 2. `pterm status <app_id>`
46
- 3. `pterm run <app_path> [--default <selector>]...`
47
- 4. `pterm logs <app_id> --tail 200`
48
- 5. `pterm which <command>`
49
- 6. `pterm stars` (optional: inspect user-pinned favorites)
50
- 7. `pterm star <app_id>` / `pterm unstar <app_id>` (only when user explicitly asks to change preference)
51
- 8. `pterm registry search "<query>"` (only after no suitable local result and user approval)
52
- 9. `pterm download <uri> [name]` (only after a registry result is selected)
43
+ `pterm search`, `pterm status`, `pterm run`, `pterm logs`, `pterm which`, `pterm stars`, `pterm star` / `pterm unstar`, `pterm registry search`, `pterm download`
53
44
 
54
45
  Do not run update commands from this skill.
55
- Use `pterm download` only after local search fails, the user approves registry search, and a registry app is selected.
56
46
  Once a Pinokio-managed app is selected, treat `pterm` and the launcher-managed interfaces as the source of truth for lifecycle and execution. Do not switch to repo-local CLIs or bundled app binaries unless the user explicitly asks for CLI mode.
57
47
 
58
48
  ## How to use
@@ -94,10 +84,12 @@ Follow these sections in order:
94
84
  - higher `launch_count_total` (if available)
95
85
  - more recent `last_launch_at` (if available)
96
86
  - higher `score`
97
- - Do not choose an offline app over a relevant `ready` or `running` app.
98
- - Do not choose a non-starred app over a relevant starred app in the same runtime tier unless the starred app is clearly not a useful match.
99
87
  - If the top candidate is not clearly better than alternatives, ask user once with top 3 candidates.
100
88
  - If a suitable installed app is found, select it and continue to Run App.
89
+ - Federated search may return apps from other Pinokio machines on the LAN:
90
+ - remote results use `app_id` in the form `<app_id>@<source.host>`
91
+ - `source.local=false` means the result is from another machine
92
+ - treat remote results as separate apps; do not merge them with the local app of the same name
101
93
 
102
94
  ### 2. Registry Fallback
103
95
 
@@ -110,7 +102,6 @@ Follow these sections in order:
110
102
  - if `pterm download <uri>` fails with `already exists`, ask the user for a local folder name and retry with `pterm download <uri> <name>`
111
103
  - if the user wants a specific local folder name or another copy of the same repo, use `pterm download <uri> <name>`
112
104
  - then run the downloaded app with `pterm run <local_app_path_or_name>`
113
- - Do not use `pterm registry search` automatically.
114
105
  - Do not use `pterm run <url>` for the registry flow.
115
106
 
116
107
  ### 3. Run App
@@ -121,11 +112,17 @@ Follow these sections in order:
121
112
  - `path`: absolute app path to use with `pterm run`
122
113
  - `running`: script is running
123
114
  - `ready`: app is reachable/ready
124
- - `ready_url`: base URL for API calls when available
115
+ - `ready_url`: default base URL for API calls when available
116
+ - `external_ready_urls`: optional ordered non-loopback app URLs for caller-side access; use them only when `ready_url` fails due to loopback restrictions
125
117
  - `state`: `offline | starting | online`
118
+ - `source`: machine identity for federated results
126
119
  - Use `--probe` only for readiness confirmation before first API call (or when status is uncertain).
127
120
  - Use `--timeout=<ms>` only when you need a non-default probe timeout.
128
121
  - Treat `offline` as expected before first run.
122
+ - If `app_id` contains `@<host>` or `source.local=false`, the app is remote:
123
+ - treat `path` and `ready_url` as source-local fields, not caller-usable local paths/URLs
124
+ - use `external_ready_urls` in order for caller-side API access when available
125
+ - do not assume `pterm run <path>` can launch a remote app from the current machine
129
126
  - If app is offline or not ready, run it:
130
127
  - Run `pterm run <app_path>`.
131
128
  - If the launcher has no explicit default item or the launch action depends on current menu state, infer one or more ordered selectors from the launcher's current menu and pass them via repeated `--default`.
@@ -134,8 +131,9 @@ Follow these sections in order:
134
131
  - Default startup timeout: 180s.
135
132
  - Success criteria:
136
133
  - `state=online` and `ready=true`
137
- - if `ready_url` exists, use it as API base URL
138
- - treat `ready_url` plus a generated or reused client as the default execution path for app functionality
134
+ - use `ready_url` by default
135
+ - if `ready_url` fails because the client cannot access loopback and `external_ready_urls` exists, try those URLs in order
136
+ - missing `external_ready_urls` is normal; it usually means network sharing is off
139
137
  - Failure criteria:
140
138
  - timeout before success
141
139
  - app drops back to `offline` during startup after a run attempt
@@ -176,24 +174,12 @@ Follow these sections in order:
176
174
  - Prefer returning full logs over brittle deterministic error parsing.
177
175
  - REST endpoints may be used for diagnostics only when pterm is unavailable; do not claim full install/launch lifecycle completion without compatible pterm commands.
178
176
  - Do not keep searching after app selection; move to status/run.
177
+ - Do not assume `external_ready_urls` exists; localhost-only apps are normal.
179
178
  - Do not conflate loopback access failure, sandbox denial, or missing permission with "Pinokio is not running" or "`pterm` is not installed."
180
- - On localhost permission failure, prefer asking for permission over asking the user to manually run commands.
181
- - If `127.0.0.1:42000` is blocked but local `pterm` exists, explicitly tell the user this looks like a client permission/sandbox issue.
182
-
183
- ## Example A (Capability Only)
184
-
185
- User: "Generate TTS from this text: hello world"
186
-
187
- 1. Use the Search App workflow with `pterm search "tts speech synthesis" --mode balanced --min-match 2 --limit 8`.
188
- 2. If a suitable installed app is found, continue to Run App.
189
- 3. Otherwise, use the Registry Fallback workflow:
190
- - ask before `pterm registry search`
191
- - after selection: `pterm download <uri>`
192
- - if needed after `already exists`, ask for a local folder name and retry with `pterm download <uri> <name>`
193
- - then `pterm run <local_app_path_or_name>`
194
- 4. Continue with Run App and then API Call Strategy.
179
+ - On `pterm` permission failure, prefer asking for permission over asking the user to manually run commands.
180
+ - If `pterm` exists locally but cannot reach the control plane, explicitly tell the user this looks like a client permission/sandbox issue.
195
181
 
196
- ## Example B (No Launcher Default)
182
+ ## Example
197
183
 
198
184
  User: "Launch FaceFusion"
199
185
 
package/server/index.js CHANGED
@@ -4119,6 +4119,31 @@ class Server {
4119
4119
  }
4120
4120
  return accessPoints
4121
4121
  }
4122
+ persistAccessConfig() {
4123
+ if (!this.kernel || !this.kernel.store) {
4124
+ return
4125
+ }
4126
+ const host = this.kernel && this.kernel.peer && this.kernel.peer.host
4127
+ ? String(this.kernel.peer.host).trim()
4128
+ : ''
4129
+ const candidates = Array.isArray(this.kernel?.peer?.host_candidates)
4130
+ ? this.kernel.peer.host_candidates
4131
+ .map((candidate) => candidate && candidate.address ? String(candidate.address).trim() : '')
4132
+ .filter(Boolean)
4133
+ : []
4134
+ const accessHost = host || candidates[0] || ''
4135
+ if (!accessHost || accessHost === '127.0.0.1' || accessHost === 'localhost') {
4136
+ this.kernel.store.delete('access')
4137
+ return
4138
+ }
4139
+ this.kernel.store.set('access', {
4140
+ protocol: 'http',
4141
+ host: accessHost,
4142
+ port: this.port,
4143
+ candidates: Array.from(new Set([accessHost].concat(candidates))),
4144
+ updated_at: new Date().toISOString()
4145
+ })
4146
+ }
4122
4147
  async composePeerAccessPayload() {
4123
4148
  let peer_access_points = []
4124
4149
  try {
@@ -5066,6 +5091,7 @@ class Server {
5066
5091
  await Environment.init({}, this.kernel)
5067
5092
  }
5068
5093
  this.kernel.server_port = this.port
5094
+ this.persistAccessConfig()
5069
5095
  this.kernel.peer.start(this.kernel)
5070
5096
 
5071
5097
 
@@ -5190,10 +5216,14 @@ class Server {
5190
5216
  this.app.use("/web", express.static(path.resolve(__dirname, "..", "..", "web")))
5191
5217
  this.app.set('view engine', 'ejs');
5192
5218
  this.app.use((req, res, next) => {
5193
- let protocol = req.get('X-Forwarded-Proto') || "http"
5219
+ const peerForwarded = (req.get('X-Pinokio-Peer') || '').trim().toLowerCase()
5220
+ const allowPeerSourceOverride = peerForwarded === '1' || peerForwarded === 'true'
5221
+ const forwardedProtocol = allowPeerSourceOverride ? req.get('X-Pinokio-Source-Proto') : ''
5222
+ const forwardedHost = allowPeerSourceOverride ? req.get('X-Pinokio-Source-Host') : ''
5223
+ let protocol = forwardedProtocol || req.get('X-Forwarded-Proto') || "http"
5194
5224
  req.$source = {
5195
5225
  protocol,
5196
- host: req.get("host")
5226
+ host: forwardedHost || req.get("host")
5197
5227
  }
5198
5228
  next()
5199
5229
  })
@@ -11,6 +11,173 @@ class AppRegistryService {
11
11
  this.kernel = kernel
12
12
  }
13
13
 
14
+ isLoopbackHostname(hostname = '') {
15
+ const normalized = String(hostname || '').trim().toLowerCase()
16
+ if (!normalized) {
17
+ return false
18
+ }
19
+ return normalized === 'localhost' ||
20
+ normalized === '0.0.0.0' ||
21
+ normalized === '::1' ||
22
+ normalized === '[::1]' ||
23
+ normalized.startsWith('127.')
24
+ }
25
+
26
+ normalizeSource(source = null) {
27
+ const protocolRaw = source && typeof source.protocol === 'string' ? source.protocol.trim().toLowerCase() : ''
28
+ const protocol = protocolRaw === 'https' ? 'https' : 'http'
29
+ const host = source && typeof source.host === 'string' ? source.host.trim() : ''
30
+ let hostname = ''
31
+ if (host) {
32
+ try {
33
+ hostname = new URL(`http://${host}`).hostname
34
+ } catch (_) {
35
+ hostname = ''
36
+ }
37
+ }
38
+ return {
39
+ protocol,
40
+ host,
41
+ hostname: hostname ? hostname.toLowerCase() : ''
42
+ }
43
+ }
44
+
45
+ findExternalHostEntries(port) {
46
+ const normalizedPort = Number.parseInt(String(port || ''), 10)
47
+ if (!Number.isFinite(normalizedPort) || normalizedPort <= 0) {
48
+ return []
49
+ }
50
+ const currentHost = this.kernel && this.kernel.peer ? this.kernel.peer.host : ''
51
+ const peerInfo = currentHost && this.kernel && this.kernel.peer && this.kernel.peer.info
52
+ ? this.kernel.peer.info[currentHost]
53
+ : null
54
+ const routerInfo = peerInfo && Array.isArray(peerInfo.router_info) ? peerInfo.router_info : []
55
+ const entries = []
56
+ const seen = new Set()
57
+ for (const item of routerInfo) {
58
+ if (!item || String(item.internal_port) !== String(normalizedPort)) {
59
+ continue
60
+ }
61
+ const externalHosts = Array.isArray(item.external_hosts) ? item.external_hosts : []
62
+ for (const externalHost of externalHosts) {
63
+ if (!externalHost || !externalHost.host || !externalHost.port) {
64
+ continue
65
+ }
66
+ const signature = `${externalHost.host}:${externalHost.port}`
67
+ if (seen.has(signature)) {
68
+ continue
69
+ }
70
+ seen.add(signature)
71
+ entries.push({
72
+ host: String(externalHost.host).trim(),
73
+ port: Number.parseInt(String(externalHost.port), 10),
74
+ scope: externalHost.scope || null,
75
+ interface: externalHost.interface || null
76
+ })
77
+ }
78
+ if (item.external_ip && typeof item.external_ip === 'string') {
79
+ try {
80
+ const parsed = new URL(`http://${item.external_ip}`)
81
+ const signature = `${parsed.hostname}:${parsed.port}`
82
+ if (!seen.has(signature) && parsed.hostname && parsed.port) {
83
+ seen.add(signature)
84
+ entries.push({
85
+ host: parsed.hostname,
86
+ port: Number.parseInt(parsed.port, 10),
87
+ scope: null,
88
+ interface: null
89
+ })
90
+ }
91
+ } catch (_) {}
92
+ }
93
+ }
94
+ return entries
95
+ }
96
+
97
+ sortExternalHostEntries(entries = [], source = null) {
98
+ const sourceInfo = this.normalizeSource(source)
99
+ const scopedEntries = Array.isArray(entries) ? entries.slice() : []
100
+ const scopeRank = (scope = '') => {
101
+ switch (String(scope || '').toLowerCase()) {
102
+ case 'lan':
103
+ return 0
104
+ case 'cgnat':
105
+ return 1
106
+ default:
107
+ return 2
108
+ }
109
+ }
110
+ return scopedEntries.sort((a, b) => {
111
+ const aHost = String(a && a.host ? a.host : '').toLowerCase()
112
+ const bHost = String(b && b.host ? b.host : '').toLowerCase()
113
+ const aMatchesCaller = sourceInfo.hostname && aHost === sourceInfo.hostname ? 1 : 0
114
+ const bMatchesCaller = sourceInfo.hostname && bHost === sourceInfo.hostname ? 1 : 0
115
+ if (aMatchesCaller !== bMatchesCaller) {
116
+ return bMatchesCaller - aMatchesCaller
117
+ }
118
+ const aScope = scopeRank(a && a.scope)
119
+ const bScope = scopeRank(b && b.scope)
120
+ if (aScope !== bScope) {
121
+ return aScope - bScope
122
+ }
123
+ return `${aHost}:${a && a.port ? a.port : ''}`.localeCompare(`${bHost}:${b && b.port ? b.port : ''}`)
124
+ })
125
+ }
126
+
127
+ buildExternalReadyUrls(url, source = null) {
128
+ if (!url || typeof url !== 'string') {
129
+ return []
130
+ }
131
+ const originalUrl = String(url)
132
+ let parsed
133
+ try {
134
+ parsed = new URL(originalUrl)
135
+ } catch (_) {
136
+ return []
137
+ }
138
+ const originalProtocol = parsed.protocol === 'https:' ? 'https:' : 'http:'
139
+ const hostname = (parsed.hostname || '').trim().toLowerCase()
140
+ if (!hostname || !this.isLoopbackHostname(hostname)) {
141
+ return []
142
+ }
143
+
144
+ const externalEntries = this.sortExternalHostEntries(this.findExternalHostEntries(parsed.port), source)
145
+ const results = []
146
+ const seen = new Set()
147
+ for (const entry of externalEntries) {
148
+ if (!entry || !entry.host || !entry.port) {
149
+ continue
150
+ }
151
+ const next = new URL(parsed.toString())
152
+ next.protocol = originalProtocol
153
+ next.hostname = entry.host
154
+ next.port = String(entry.port)
155
+ let nextUrl = next.toString()
156
+ if (/^https?:\/\/[^/?#]+$/i.test(originalUrl) && next.pathname === '/' && !next.search && !next.hash) {
157
+ nextUrl = nextUrl.replace(/\/$/, '')
158
+ }
159
+ if (seen.has(nextUrl)) {
160
+ continue
161
+ }
162
+ seen.add(nextUrl)
163
+ const item = {
164
+ url: nextUrl,
165
+ transport: 'ip',
166
+ scope: entry.scope || 'unknown'
167
+ }
168
+ if (entry.interface) {
169
+ item.interface = entry.interface
170
+ }
171
+ results.push(item)
172
+ }
173
+ return results
174
+ }
175
+
176
+ buildExternalReadyUrl(url, source = null) {
177
+ const externalReadyUrls = this.buildExternalReadyUrls(url, source)
178
+ return externalReadyUrls.length > 0 ? externalReadyUrls[0].url : null
179
+ }
180
+
14
181
  isPathWithin(parentPath, childPath) {
15
182
  if (!parentPath || !childPath) {
16
183
  return false
@@ -98,6 +265,7 @@ class AppRegistryService {
98
265
  ready: false,
99
266
  state: 'offline',
100
267
  ready_url: null,
268
+ external_ready_urls: [],
101
269
  ready_script: null,
102
270
  running_scripts: [],
103
271
  local_entries: []
@@ -275,6 +443,7 @@ class AppRegistryService {
275
443
  }
276
444
 
277
445
  const runtime = this.collectAppRuntime(appRoot)
446
+ runtime.external_ready_urls = this.buildExternalReadyUrls(runtime.ready_url, options.source || null)
278
447
  const installScript = await this.firstExistingScript(appRoot, ['install.js', 'install.json'])
279
448
  const startScript = await this.firstExistingScript(appRoot, ['start.js', 'start.json'])
280
449
  let defaultTarget = null
@@ -312,6 +481,7 @@ class AppRegistryService {
312
481
  running: runtime.running,
313
482
  ready,
314
483
  ready_url: runtime.ready_url,
484
+ external_ready_urls: runtime.external_ready_urls,
315
485
  state,
316
486
  running_scripts: runtime.running_scripts,
317
487
  ready_script: runtime.ready_script,
@@ -512,15 +512,17 @@ class AppSearchService {
512
512
  })
513
513
  }
514
514
 
515
- decorateAppWithRuntime(app, extras = {}) {
515
+ decorateAppWithRuntime(app, extras = {}, source = null) {
516
516
  const appRoot = this.kernel.path('api', app.name)
517
517
  const runtime = this.registry.collectAppRuntime(appRoot)
518
+ const externalReadyUrls = this.registry.buildExternalReadyUrls(runtime.ready_url, source)
518
519
  return {
519
520
  app_id: app.name,
520
521
  ...app,
521
522
  running: runtime.running,
522
523
  ready: runtime.ready,
523
524
  ready_url: runtime.ready_url,
525
+ external_ready_urls: externalReadyUrls,
524
526
  state: runtime.state,
525
527
  ...extras
526
528
  }
@@ -530,6 +532,7 @@ class AppSearchService {
530
532
  const options = rawOptions && typeof rawOptions === 'object' ? rawOptions : {}
531
533
  const q = typeof query === 'string' ? query.trim() : ''
532
534
  const normalizedQuery = q.toLowerCase()
535
+ const source = options.source || null
533
536
  const searchMode = this.normalizeSearchMode(options.mode)
534
537
  const resultLimit = this.parsePositiveInteger(options.limit, APP_SEARCH_DEFAULT_LIMIT)
535
538
  const preferenceMap = await this.readPreferenceMap()
@@ -546,7 +549,7 @@ class AppSearchService {
546
549
  const apps = rankedEntries
547
550
  .map((entry) => this.decorateAppWithRuntime(entry.app, {
548
551
  ...this.toPublicPreference(entry.preference)
549
- }))
552
+ }, source))
550
553
  .slice(0, resultLimit)
551
554
  return {
552
555
  q: normalizedQuery,
@@ -692,7 +695,7 @@ class AppSearchService {
692
695
  matched_terms_count: matchedTerms.length,
693
696
  matched_terms: matchedTerms,
694
697
  ...this.toPublicPreference(entry.preference)
695
- })
698
+ }, source)
696
699
  })
697
700
  return {
698
701
  q: normalizedQuery,
@@ -719,7 +722,7 @@ class AppSearchService {
719
722
  const apps = rankedEntries
720
723
  .map((entry) => this.decorateAppWithRuntime(entry.app, {
721
724
  ...this.toPublicPreference(entry.preference)
722
- }))
725
+ }, source))
723
726
  .slice(0, resultLimit)
724
727
  return {
725
728
  q: normalizedQuery,
@@ -1,4 +1,53 @@
1
1
  const express = require('express')
2
+ const axios = require('axios')
3
+
4
+ const DEFAULT_PEER_PORT = 42000
5
+ const DEFAULT_PEER_TIMEOUT_MS = 2500
6
+ const IPV4_HOST_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/
7
+
8
+ const isQualifiedHost = (value = '') => {
9
+ return IPV4_HOST_PATTERN.test(String(value || '').trim())
10
+ }
11
+
12
+ const parseQualifiedAppId = (value = '') => {
13
+ if (typeof value !== 'string') {
14
+ return {
15
+ app_id: '',
16
+ host: null,
17
+ qualified: false
18
+ }
19
+ }
20
+ const trimmed = value.trim()
21
+ if (!trimmed) {
22
+ return {
23
+ app_id: '',
24
+ host: null,
25
+ qualified: false
26
+ }
27
+ }
28
+ const atIndex = trimmed.lastIndexOf('@')
29
+ if (atIndex <= 0 || atIndex >= trimmed.length - 1) {
30
+ return {
31
+ app_id: trimmed,
32
+ host: null,
33
+ qualified: false
34
+ }
35
+ }
36
+ const appId = trimmed.slice(0, atIndex).trim()
37
+ const host = trimmed.slice(atIndex + 1).trim()
38
+ if (!appId || !isQualifiedHost(host)) {
39
+ return {
40
+ app_id: trimmed,
41
+ host: null,
42
+ qualified: false
43
+ }
44
+ }
45
+ return {
46
+ app_id: appId,
47
+ host,
48
+ qualified: true
49
+ }
50
+ }
2
51
 
3
52
  module.exports = function registerAppRoutes(app, { registry, preferences, appSearch, appLogs, getTheme }) {
4
53
  if (!app || !registry || !preferences || !appSearch || !appLogs) {
@@ -34,6 +83,145 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
34
83
  }
35
84
  return fallback
36
85
  }
86
+ const includePeersForSearch = (req) => {
87
+ const scope = typeof req.query.peer_scope === 'string' ? req.query.peer_scope.trim().toLowerCase() : ''
88
+ if (scope === 'local') {
89
+ return false
90
+ }
91
+ return parseBooleanInput(req.query.include_peers, true)
92
+ }
93
+ const currentPeerHost = () => {
94
+ return registry?.kernel?.peer?.host ? String(registry.kernel.peer.host).trim() : ''
95
+ }
96
+ const currentPeerName = () => {
97
+ return registry?.kernel?.peer?.name ? String(registry.kernel.peer.name).trim() : ''
98
+ }
99
+ const buildSource = (host, local = false) => {
100
+ const peerInfo = host && registry?.kernel?.peer?.info ? registry.kernel.peer.info[host] : null
101
+ const name = peerInfo && peerInfo.name ? peerInfo.name : (local ? currentPeerName() : '')
102
+ return {
103
+ host: host || null,
104
+ name: name || host || null,
105
+ local: Boolean(local)
106
+ }
107
+ }
108
+ const qualifyAppId = (appId, host) => {
109
+ const normalizedAppId = typeof appId === 'string' ? appId.trim() : ''
110
+ if (!normalizedAppId || !host || host === currentPeerHost()) {
111
+ return normalizedAppId
112
+ }
113
+ return `${normalizedAppId}@${host}`
114
+ }
115
+ const decorateSearchResult = (appResult, source) => {
116
+ const next = appResult && typeof appResult === 'object' ? { ...appResult } : {}
117
+ next.app_id = qualifyAppId(next.app_id || next.name || '', source.host)
118
+ next.source = source
119
+ if (!source.local) {
120
+ next.ready_url = null
121
+ }
122
+ return next
123
+ }
124
+ const decorateStatusResult = (statusResult, source) => {
125
+ const next = statusResult && typeof statusResult === 'object' ? { ...statusResult } : {}
126
+ next.app_id = qualifyAppId(next.app_id || next.name || '', source.host)
127
+ next.source = source
128
+ if (!source.local) {
129
+ next.ready_url = null
130
+ }
131
+ return next
132
+ }
133
+ const peerRequestHeaders = (req) => {
134
+ const headers = {
135
+ 'x-pinokio-peer': '1'
136
+ }
137
+ if (req && req.$source && typeof req.$source.host === 'string' && req.$source.host.trim()) {
138
+ headers['x-pinokio-source-host'] = req.$source.host.trim()
139
+ }
140
+ if (req && req.$source && typeof req.$source.protocol === 'string' && req.$source.protocol.trim()) {
141
+ headers['x-pinokio-source-proto'] = req.$source.protocol.trim()
142
+ }
143
+ return headers
144
+ }
145
+ const peerPort = () => {
146
+ const rawPort = Number.parseInt(String(registry?.kernel?.peer?.default_port || registry?.kernel?.server_port || DEFAULT_PEER_PORT), 10)
147
+ return Number.isFinite(rawPort) && rawPort > 0 ? rawPort : DEFAULT_PEER_PORT
148
+ }
149
+ const remotePeerHosts = () => {
150
+ const localHost = currentPeerHost()
151
+ const info = registry?.kernel?.peer?.info
152
+ if (!info || typeof info !== 'object') {
153
+ return []
154
+ }
155
+ return Object.keys(info).filter((host) => host && host !== localHost)
156
+ }
157
+ const mergeSearchApps = (localApps, remoteApps, query = '') => {
158
+ const merged = []
159
+ const seen = new Set()
160
+ const push = (items = []) => {
161
+ for (const item of items) {
162
+ if (!item || !item.app_id) {
163
+ continue
164
+ }
165
+ if (seen.has(item.app_id)) {
166
+ continue
167
+ }
168
+ seen.add(item.app_id)
169
+ merged.push(item)
170
+ }
171
+ }
172
+ push(localApps)
173
+ push(remoteApps)
174
+ const normalizedQuery = typeof query === 'string' ? query.trim() : ''
175
+ if (!normalizedQuery) {
176
+ return merged
177
+ }
178
+ return merged.sort((a, b) => {
179
+ const aAdjusted = typeof a.adjusted_score === 'number' ? a.adjusted_score : -Infinity
180
+ const bAdjusted = typeof b.adjusted_score === 'number' ? b.adjusted_score : -Infinity
181
+ if (aAdjusted !== bAdjusted) {
182
+ return bAdjusted - aAdjusted
183
+ }
184
+ const aScore = typeof a.score === 'number' ? a.score : -Infinity
185
+ const bScore = typeof b.score === 'number' ? b.score : -Infinity
186
+ if (aScore !== bScore) {
187
+ return bScore - aScore
188
+ }
189
+ return String(a.app_id || '').localeCompare(String(b.app_id || ''))
190
+ })
191
+ }
192
+ const fetchPeerSearchResults = async (req, { q, mode, minMatch, limit }) => {
193
+ const hosts = remotePeerHosts()
194
+ if (hosts.length === 0) {
195
+ return []
196
+ }
197
+ const timeout = DEFAULT_PEER_TIMEOUT_MS
198
+ const port = peerPort()
199
+ const headers = peerRequestHeaders(req)
200
+ const requests = hosts.map(async (host) => {
201
+ try {
202
+ const response = await axios.get(`http://${host}:${port}/apps/search`, {
203
+ timeout,
204
+ headers,
205
+ params: {
206
+ q,
207
+ mode,
208
+ min_match: minMatch,
209
+ limit,
210
+ peer_scope: 'local'
211
+ }
212
+ })
213
+ const source = buildSource(host, false)
214
+ const apps = Array.isArray(response?.data?.apps)
215
+ ? response.data.apps.map((appResult) => decorateSearchResult(appResult, source))
216
+ : []
217
+ return apps
218
+ } catch (_) {
219
+ return []
220
+ }
221
+ })
222
+ const results = await Promise.all(requests)
223
+ return results.flat()
224
+ }
37
225
 
38
226
  router.get('/apps/preferences', asyncHandler(async (req, res) => {
39
227
  const queryAppId = typeof req.query.app_id === 'string' ? registry.normalizeAppId(req.query.app_id) : ''
@@ -98,15 +286,44 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
98
286
  const payload = await appSearch.searchApps(q, {
99
287
  mode,
100
288
  min_match: minMatch,
101
- limit
289
+ limit,
290
+ source: req.$source || null
102
291
  })
292
+ const localSource = buildSource(currentPeerHost(), true)
293
+ payload.apps = Array.isArray(payload.apps)
294
+ ? payload.apps.map((appResult) => decorateSearchResult(appResult, localSource))
295
+ : []
296
+ if (includePeersForSearch(req)) {
297
+ const remoteApps = await fetchPeerSearchResults(req, { q, mode, minMatch, limit })
298
+ payload.apps = mergeSearchApps(payload.apps, remoteApps, q)
299
+ payload.count = payload.apps.length
300
+ }
103
301
  res.json(payload)
104
302
  }))
105
303
  router.get('/apps/search/test', asyncHandler(async (req, res) => {
106
304
  const q = typeof req.query.q === 'string' ? req.query.q : ''
107
305
  const payload = q
108
- ? await appSearch.searchApps(q)
306
+ ? await appSearch.searchApps(q, {
307
+ source: req.$source || null,
308
+ mode: typeof req.query.mode === 'string' ? req.query.mode : '',
309
+ min_match: typeof req.query.min_match === 'string' ? req.query.min_match : '',
310
+ limit: typeof req.query.limit === 'string' ? req.query.limit : ''
311
+ })
109
312
  : { q: '', count: 0, apps: [] }
313
+ const localSource = buildSource(currentPeerHost(), true)
314
+ payload.apps = Array.isArray(payload.apps)
315
+ ? payload.apps.map((appResult) => decorateSearchResult(appResult, localSource))
316
+ : []
317
+ if (q && includePeersForSearch(req)) {
318
+ const remoteApps = await fetchPeerSearchResults(req, {
319
+ q,
320
+ mode: typeof req.query.mode === 'string' ? req.query.mode : '',
321
+ minMatch: typeof req.query.min_match === 'string' ? req.query.min_match : '',
322
+ limit: typeof req.query.limit === 'string' ? req.query.limit : ''
323
+ })
324
+ payload.apps = mergeSearchApps(payload.apps, remoteApps, q)
325
+ payload.count = payload.apps.length
326
+ }
110
327
  res.render('app_search_test', {
111
328
  theme: readTheme(),
112
329
  agent: req.agent || '',
@@ -117,7 +334,41 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
117
334
  }))
118
335
 
119
336
  router.get('/apps/status/:app_id', asyncHandler(async (req, res) => {
120
- const appId = registry.normalizeAppId(req.params.app_id)
337
+ const parsedAppId = parseQualifiedAppId(req.params.app_id)
338
+ const requestedAppId = parsedAppId.app_id || req.params.app_id
339
+ const remoteHost = parsedAppId.qualified ? parsedAppId.host : null
340
+ if (remoteHost && remoteHost !== currentPeerHost()) {
341
+ try {
342
+ const timeout = Number.parseInt(String(req.query.timeout || ''), 10)
343
+ const params = {}
344
+ if (typeof req.query.probe !== 'undefined') {
345
+ params.probe = req.query.probe
346
+ }
347
+ if (Number.isFinite(timeout)) {
348
+ params.timeout = String(timeout)
349
+ }
350
+ const response = await axios.get(`http://${remoteHost}:${peerPort()}/apps/status/${encodeURIComponent(requestedAppId)}`, {
351
+ timeout: DEFAULT_PEER_TIMEOUT_MS,
352
+ headers: peerRequestHeaders(req),
353
+ params
354
+ })
355
+ const payload = decorateStatusResult(response.data, buildSource(remoteHost, false))
356
+ res.json(payload)
357
+ return
358
+ } catch (error) {
359
+ if (error && error.response) {
360
+ res.status(error.response.status).json(error.response.data)
361
+ return
362
+ }
363
+ res.status(502).json({
364
+ error: 'Peer status unavailable',
365
+ app_id: qualifyAppId(requestedAppId, remoteHost),
366
+ source: buildSource(remoteHost, false)
367
+ })
368
+ return
369
+ }
370
+ }
371
+ const appId = registry.normalizeAppId(requestedAppId)
121
372
  if (!appId) {
122
373
  res.status(400).json({ error: 'Invalid app_id' })
123
374
  return
@@ -126,14 +377,15 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
126
377
  const timeout = Number.parseInt(String(req.query.timeout || ''), 10)
127
378
  const status = await registry.buildAppStatus(appId, {
128
379
  probe,
129
- timeout: Number.isFinite(timeout) ? timeout : 1500
380
+ timeout: Number.isFinite(timeout) ? timeout : 1500,
381
+ source: req.$source || null
130
382
  })
131
383
  if (!status) {
132
384
  res.status(404).json({ error: 'App not found', app_id: appId })
133
385
  return
134
386
  }
135
387
  status.preference = await preferences.getPreference(appId)
136
- res.json(status)
388
+ res.json(decorateStatusResult(status, buildSource(currentPeerHost(), true)))
137
389
  }))
138
390
 
139
391
  router.get('/apps/logs/:app_id', asyncHandler(async (req, res) => {
@@ -142,7 +394,9 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
142
394
  res.status(400).json({ error: 'Invalid app_id' })
143
395
  return
144
396
  }
145
- const status = await registry.buildAppStatus(appId)
397
+ const status = await registry.buildAppStatus(appId, {
398
+ source: req.$source || null
399
+ })
146
400
  if (!status) {
147
401
  res.status(404).json({ error: 'App not found', app_id: appId })
148
402
  return
package/server/socket.js CHANGED
@@ -73,6 +73,46 @@ class Socket {
73
73
  }
74
74
  return ""
75
75
  }
76
+ sourceFromBoundUrl(boundUrl = "") {
77
+ if (typeof boundUrl !== "string") {
78
+ return null
79
+ }
80
+ const trimmed = boundUrl.trim()
81
+ if (!trimmed) {
82
+ return null
83
+ }
84
+ try {
85
+ const parsed = new URL(trimmed)
86
+ return {
87
+ protocol: parsed.protocol === "https:" || parsed.protocol === "wss:" ? "https" : "http",
88
+ host: parsed.host
89
+ }
90
+ } catch (error) {
91
+ return null
92
+ }
93
+ }
94
+ projectLaunchTargetUri(uri, ws) {
95
+ if (typeof uri !== "string" || !/^https?:\/\//i.test(uri)) {
96
+ return uri
97
+ }
98
+ const registry = this.parent && this.parent.appRegistry
99
+ if (!registry || typeof registry.buildExternalReadyUrl !== "function") {
100
+ return uri
101
+ }
102
+ const source = this.sourceFromBoundUrl(ws && ws._boundUrl ? ws._boundUrl : "")
103
+ if (!source || typeof registry.normalizeSource !== "function") {
104
+ return uri
105
+ }
106
+ const sourceInfo = registry.normalizeSource(source)
107
+ if (!sourceInfo || !sourceInfo.hostname || (
108
+ typeof registry.isLoopbackHostname === "function" &&
109
+ registry.isLoopbackHostname(sourceInfo.hostname)
110
+ )) {
111
+ return uri
112
+ }
113
+ const projected = registry.buildExternalReadyUrl(uri, source)
114
+ return projected || uri
115
+ }
76
116
  trackLaunchTelemetry(scriptPath = "", source = "unknown") {
77
117
  const appPreferences = this.parent && this.parent.appPreferences
78
118
  if (!appPreferences || typeof appPreferences.recordLaunchByPath !== "function") {
@@ -198,9 +238,12 @@ class Socket {
198
238
  let id = this.parent.kernel.api.filePath(req.uri)
199
239
  try {
200
240
  let defaultTarget = await getLaunchTarget(this.parent.kernel.api, id, req.default)
241
+ const launchTargetUri = defaultTarget && defaultTarget.uri
242
+ ? this.projectLaunchTargetUri(defaultTarget.uri, ws)
243
+ : undefined
201
244
  ws.send(JSON.stringify({
202
245
  data: {
203
- uri: defaultTarget ? defaultTarget.uri : undefined,
246
+ uri: launchTargetUri,
204
247
  input: defaultTarget ? defaultTarget.input : undefined
205
248
  }
206
249
  }))
@@ -4384,12 +4384,14 @@ header.navheader .mode-selector .community-mode-toggle {
4384
4384
  box-sizing: border-box;
4385
4385
  }
4386
4386
  .mobile-bottom-nav {
4387
- position: relative;
4387
+ position: sticky;
4388
+ bottom: 0;
4388
4389
  display: flex;
4389
4390
  flex: 0 0 auto;
4390
4391
  align-items: center;
4391
4392
  gap: 3px;
4392
4393
  width: 100%;
4394
+ margin-top: auto;
4393
4395
  min-height: 32px;
4394
4396
  padding: 0 4px env(safe-area-inset-bottom) 4px;
4395
4397
  border-top: 1px solid rgba(0,0,0,0.08);