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 +4 -1
- package/package.json +1 -1
- package/prototype/system/SKILL_PINOKIO.md +40 -54
- package/server/index.js +32 -2
- package/server/lib/app_registry.js +170 -0
- package/server/lib/app_search.js +7 -4
- package/server/routes/apps.js +260 -6
- package/server/socket.js +44 -1
- package/server/views/app.ejs +3 -1
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)
|
|
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
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
138
|
-
-
|
|
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
|
|
181
|
-
- If `
|
|
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
|
|
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
|
-
|
|
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,
|
package/server/lib/app_search.js
CHANGED
|
@@ -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,
|
package/server/routes/apps.js
CHANGED
|
@@ -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
|
|
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:
|
|
246
|
+
uri: launchTargetUri,
|
|
204
247
|
input: defaultTarget ? defaultTarget.input : undefined
|
|
205
248
|
}
|
|
206
249
|
}))
|
package/server/views/app.ejs
CHANGED
|
@@ -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:
|
|
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);
|