@rubytech/create-realagent 1.0.775 → 1.0.789
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/dist/index.js +30 -39
- package/package.json +1 -1
- package/payload/platform/plugins/docs/references/troubleshooting.md +1 -1
- package/payload/platform/scripts/wifi-provision-server/server.js +157 -59
- package/payload/platform/scripts/wifi-provision.sh +88 -8
- package/payload/server/chunk-FEEKJZOY.js +9841 -0
- package/payload/server/maxy-edge.js +80 -24
- package/payload/server/public/assets/{Checkbox-C_KxaLc-.js → Checkbox-DZgcVkLU.js} +1 -1
- package/payload/server/public/assets/{admin-xbKPR6ZI.js → admin-pSseUJFx.js} +1 -1
- package/payload/server/public/assets/data-Dbl98u-8.js +1 -0
- package/payload/server/public/assets/graph-B6QNcEdZ.js +1 -0
- package/payload/server/public/assets/{jsx-runtime-BZtBxBng.css → jsx-runtime-CSZKQ_0M.css} +1 -1
- package/payload/server/public/assets/{page-CjTfZ3O6.js → page-DUiVzuAl.js} +1 -1
- package/payload/server/public/assets/{page-DEWgk_nR.js → page-DzXDy3ON.js} +1 -1
- package/payload/server/public/assets/{public-CehiL-qZ.js → public--QCAsuXZ.js} +1 -1
- package/payload/server/public/assets/{share-2-BG1VXt3z.js → share-2-DHxwin_o.js} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-1Dvb-yHn.js → useVoiceRecorder-DNApywpF.js} +1 -1
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +8 -8
- package/payload/server/public/public.html +5 -5
- package/payload/server/server.js +1 -1
- package/payload/platform/scripts/embed-backfill.sh +0 -382
- package/payload/server/public/assets/data-D23IzpJ2.js +0 -1
- package/payload/server/public/assets/graph-D2AS9zFS.js +0 -1
- /package/payload/server/public/assets/{jsx-runtime-DrneHL3t.js → jsx-runtime-Fjep7VIt.js} +0 -0
package/dist/index.js
CHANGED
|
@@ -430,6 +430,18 @@ function installSystemDeps() {
|
|
|
430
430
|
console.log(` Avahi host-name: ${HOSTNAME_FLAG} (updated avahi-daemon.conf)`);
|
|
431
431
|
}
|
|
432
432
|
catch { /* avahi-daemon.conf may not exist — non-critical */ }
|
|
433
|
+
// Restart avahi-daemon so the new hostname takes effect immediately
|
|
434
|
+
// and any stale "maxytest-2" auto-renamed records from a previous
|
|
435
|
+
// boot's hostname-conflict cycle are withdrawn. Without this,
|
|
436
|
+
// avahi-resolve -n <hostname>.local times out from the device itself
|
|
437
|
+
// because the daemon is still advertising the previous identity.
|
|
438
|
+
try {
|
|
439
|
+
console.log(" [privileged] systemctl restart avahi-daemon");
|
|
440
|
+
shell("systemctl", ["restart", "avahi-daemon"], { sudo: true });
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
console.error(` WARNING: avahi-daemon restart failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
444
|
+
}
|
|
433
445
|
}
|
|
434
446
|
catch (err) {
|
|
435
447
|
console.error(` WARNING: Failed to set hostname to '${HOSTNAME_FLAG}': ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1346,6 +1358,19 @@ function buildPlatform() {
|
|
|
1346
1358
|
// server/package.json but NOT shipped as pre-built node_modules — npm pack silently
|
|
1347
1359
|
// strips files from nested node_modules (e.g. rxjs/package.json), breaking require().
|
|
1348
1360
|
// Install fresh on device to guarantee a complete dependency tree.
|
|
1361
|
+
//
|
|
1362
|
+
// On upgrade, wipe `node_modules` first so npm extracts a clean tree. Without
|
|
1363
|
+
// this, an interrupted previous install (network blip, operator cancellation,
|
|
1364
|
+
// power loss) can leave nested package.json files half-truncated — the most
|
|
1365
|
+
// common manifestation is `Error: Invalid package config .../rxjs/package.json`
|
|
1366
|
+
// at server startup, which loops the brand service indefinitely. The wipe
|
|
1367
|
+
// adds ~30 s to upgrades but eliminates a class of unrecoverable customer
|
|
1368
|
+
// states; reliability wins over speed for a one-shot install path.
|
|
1369
|
+
const serverNodeModules = join(INSTALL_DIR, "server", "node_modules");
|
|
1370
|
+
if (existsSync(serverNodeModules)) {
|
|
1371
|
+
console.log(" Wiping previous server/node_modules for a clean reinstall...");
|
|
1372
|
+
rmSync(serverNodeModules, { recursive: true, force: true });
|
|
1373
|
+
}
|
|
1349
1374
|
console.log(` Installing server dependencies (${join(INSTALL_DIR, "server")})...`);
|
|
1350
1375
|
shellRetry("npm", ["install", "--omit=dev", ...NPM_NET_FLAGS], { cwd: join(INSTALL_DIR, "server") }, 3, 15);
|
|
1351
1376
|
}
|
|
@@ -1541,11 +1566,11 @@ function setupVncViewer() {
|
|
|
1541
1566
|
}
|
|
1542
1567
|
function setupAccount() {
|
|
1543
1568
|
log("10", TOTAL, "Setting up...");
|
|
1544
|
-
//
|
|
1545
|
-
//
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1569
|
+
// Task 787 — seed-neo4j.sh hard-exits without NEO4J_URI. The installer
|
|
1570
|
+
// owns the brand-correct URI and password, so we derive them once.
|
|
1571
|
+
// Missing password file is a hard error: ensureNeo4jPassword() ran
|
|
1572
|
+
// upstream and would have thrown already if it couldn't reach the
|
|
1573
|
+
// brand's Neo4j.
|
|
1549
1574
|
const passwordFile = join(INSTALL_DIR, "platform/config/.neo4j-password");
|
|
1550
1575
|
if (!existsSync(passwordFile)) {
|
|
1551
1576
|
throw new Error(`Neo4j password file missing at ${passwordFile} — required by setup step.`);
|
|
@@ -1559,40 +1584,6 @@ function setupAccount() {
|
|
|
1559
1584
|
logFile(` [neo4j] passing NEO4J_URI=${neo4jUri} to seed`);
|
|
1560
1585
|
shell("bash", [seedScript], { cwd: INSTALL_DIR, env: neo4jEnv });
|
|
1561
1586
|
}
|
|
1562
|
-
// Task 748 — universal embedding coverage backfill. Run after seed so the
|
|
1563
|
-
// entity_search index is in place and any pre-Task-748 nodes (e.g. the
|
|
1564
|
-
// 5096 LinkedIn-imported Persons on existing Pis that bulk-import skipped
|
|
1565
|
-
// embedding for) get a vector populated. Idempotent — instant no-op when
|
|
1566
|
-
// nothing is pending, so re-running on every install is harmless.
|
|
1567
|
-
//
|
|
1568
|
-
// Failure-mode policy: WARN, do not abort. The fulltext index is already
|
|
1569
|
-
// applied above, so BM25 search works end-to-end without embeddings; the
|
|
1570
|
-
// only gap is vector ranking quality on legacy nodes. Aborting the
|
|
1571
|
-
// installer on an Ollama hiccup would block every install for a
|
|
1572
|
-
// strictly-degradable feature. The script's own loud-failure output
|
|
1573
|
-
// tells the operator how to re-run.
|
|
1574
|
-
const backfillScript = join(INSTALL_DIR, "platform/scripts/embed-backfill.sh");
|
|
1575
|
-
if (existsSync(backfillScript)) {
|
|
1576
|
-
const start = Date.now();
|
|
1577
|
-
logFile(`> bash ${backfillScript} (warn-not-abort)`);
|
|
1578
|
-
const result = spawnSync("bash", [backfillScript], {
|
|
1579
|
-
stdio: "inherit",
|
|
1580
|
-
timeout: 30 * 60_000,
|
|
1581
|
-
cwd: INSTALL_DIR,
|
|
1582
|
-
env: neo4jEnv,
|
|
1583
|
-
});
|
|
1584
|
-
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
|
1585
|
-
if (result.status !== 0 || result.signal) {
|
|
1586
|
-
const reason = result.signal ? `signal=${result.signal}` : `exit=${result.status}`;
|
|
1587
|
-
logFile(` WARN: embed-backfill non-zero (${reason}) after ${dur}s`);
|
|
1588
|
-
console.warn(`\n WARNING: embed-backfill did not complete (${reason}) — BM25 search works,\n` +
|
|
1589
|
-
` but vector ranking on legacy nodes will be sparse until you re-run:\n` +
|
|
1590
|
-
` bash ${backfillScript}\n`);
|
|
1591
|
-
}
|
|
1592
|
-
else {
|
|
1593
|
-
logFile(` OK embed-backfill in ${dur}s`);
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
1587
|
}
|
|
1597
1588
|
// ---------------------------------------------------------------------------
|
|
1598
1589
|
// Tunnel script shortcuts
|
package/package.json
CHANGED
|
@@ -125,7 +125,7 @@ If the initial Cloudflare login fails during setup, {{productName}} will fall ba
|
|
|
125
125
|
|
|
126
126
|
Task 795 — `maxy-edge.service` (always-on front door) classifies upstream errors and serves a brand-aware response. There are two distinct user-visible shapes; the right one depends on what failed.
|
|
127
127
|
|
|
128
|
-
**Branded holding page (
|
|
128
|
+
**Branded holding page (brand logo + "Starting") for ~10 s during an upgrade — this is expected and self-healing.** The edge process binds the public port immediately, but `maxy.service` (the upstream UI) takes ~10 s after restart to apply the neo4j schema and mount its 11 routes. Any browser navigation that lands during that window gets a self-contained HTML holding page that polls `/api/health` and reloads automatically once the upstream binds. The page renders the brand logo (inlined as a base64 data URI at edge boot from `<install>/server/public/brand/<assets.logo>`) and the brand display/body fonts (loaded from fonts.googleapis.com) — both paths bypass the unavailable upstream so the page never makes a same-origin asset fetch. When `brand.logoContainsName` is true the logo replaces the productName text; otherwise the page falls back to "{{productName}} is starting". No operator action required. The diagnostic line in `~/.maxy/logs/edge.log` is `[edge] upstream http error path=… err=connect ECONNREFUSED 127.0.0.1:<UPSTREAM_PORT> err-class=econnrefused-coldstart upstream=…` and disappears as soon as upstream binds. Boot-time confirmation that the logo resolved: `[edge] brand=<name> holding-logo=inlined assets-dir=<path>` — `holding-logo=missing` means the logo file wasn't found at `assets-dir`, the page degrades to text-only.
|
|
129
129
|
|
|
130
130
|
**Branded plain-text 502 ("Bad Gateway ({{productName}} unavailable)") — real upstream failure, not cold-start.** Any error class other than `ECONNREFUSED` (timeouts, resets, host-unreachable) returns the existing 502 path. The diagnostic line carries `err-class=other`. Read the log with `tail -200 ~/.maxy/logs/edge.log | rg 'err-class=other'` and check `~/.maxy/logs/server.log` for upstream stack traces — the upstream itself is the source.
|
|
131
131
|
|
|
@@ -38,11 +38,34 @@ const CONNECT_FIFO = args["connect-fifo"] || "/tmp/wifi-provision-connect";
|
|
|
38
38
|
const RESULT_FILE = args["result-file"] || "/tmp/wifi-provision-result";
|
|
39
39
|
const BRAND_JSON = args["brand-json"] || "";
|
|
40
40
|
const HOSTNAME = args.hostname || "maxy";
|
|
41
|
-
|
|
42
|
-
//
|
|
41
|
+
const PORTAL_HOST = args["portal-host"] || "";
|
|
42
|
+
// Brand service port (e.g. 19200). Distinct from PORT, which is the
|
|
43
|
+
// captive portal's own listen port (always 80). Used only to render the
|
|
44
|
+
// post-connect URL.
|
|
45
|
+
const DEVICE_PORT = parseInt(args["device-port"] || "19200", 10);
|
|
46
|
+
|
|
47
|
+
// ── Load brand config ─────────────────────────────────────────────────
|
|
48
|
+
// All visual surfaces (colour, font, logo) come from brand.json so the
|
|
49
|
+
// captive portal matches the rest of the product. Earlier this file
|
|
50
|
+
// hard-coded a generic dark navy + system font fallback that bore no
|
|
51
|
+
// relation to the brand's actual look — the operator-side complaint
|
|
52
|
+
// that surfaced this. Now: read the brand's `defaultColors`,
|
|
53
|
+
// `defaultFonts`, and `assets.logo`, fall through to neutral defaults
|
|
54
|
+
// only if a field is missing from brand.json.
|
|
43
55
|
let brandName = "Maxy";
|
|
44
|
-
|
|
45
|
-
|
|
56
|
+
const colors = {
|
|
57
|
+
primary: "#1a1a2e",
|
|
58
|
+
primaryHover: "#0f0f1f",
|
|
59
|
+
background: "#FAFAF8",
|
|
60
|
+
text: "#1a1a2e",
|
|
61
|
+
card: "rgba(0,0,0,0.04)",
|
|
62
|
+
cardBorder: "rgba(0,0,0,0.08)",
|
|
63
|
+
muted: "#6b6b6b",
|
|
64
|
+
};
|
|
65
|
+
const fonts = {
|
|
66
|
+
display: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
67
|
+
body: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
68
|
+
};
|
|
46
69
|
let logoBase64 = "";
|
|
47
70
|
|
|
48
71
|
try {
|
|
@@ -50,15 +73,29 @@ try {
|
|
|
50
73
|
const brand = JSON.parse(fs.readFileSync(BRAND_JSON, "utf-8"));
|
|
51
74
|
if (brand.productName) brandName = brand.productName;
|
|
52
75
|
|
|
53
|
-
|
|
76
|
+
if (brand.defaultColors) {
|
|
77
|
+
const c = brand.defaultColors;
|
|
78
|
+
if (c.primary) colors.primary = c.primary;
|
|
79
|
+
if (c.primaryHover) colors.primaryHover = c.primaryHover;
|
|
80
|
+
if (c.background) colors.background = c.background;
|
|
81
|
+
if (c.agentBubble) colors.card = c.agentBubble;
|
|
82
|
+
}
|
|
83
|
+
if (brand.defaultFonts) {
|
|
84
|
+
if (brand.defaultFonts.display) fonts.display = brand.defaultFonts.display;
|
|
85
|
+
if (brand.defaultFonts.body) fonts.body = brand.defaultFonts.body;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Load logo. Prefer assets.logo (the larger brand mark) and fall
|
|
89
|
+
// back to assets.icon.
|
|
54
90
|
const brandDir = path.dirname(BRAND_JSON);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
91
|
+
const logoFile = (brand.assets && (brand.assets.logo || brand.assets.icon)) || "";
|
|
92
|
+
if (logoFile) {
|
|
93
|
+
const logoPath = path.join(brandDir, "assets", logoFile);
|
|
94
|
+
if (fs.existsSync(logoPath)) {
|
|
95
|
+
const logoData = fs.readFileSync(logoPath);
|
|
96
|
+
const ext = path.extname(logoPath).slice(1);
|
|
60
97
|
const mime = ext === "png" ? "image/png" : ext === "ico" ? "image/x-icon" : "image/" + ext;
|
|
61
|
-
logoBase64 = `data:${mime};base64,${
|
|
98
|
+
logoBase64 = `data:${mime};base64,${logoData.toString("base64")}`;
|
|
62
99
|
}
|
|
63
100
|
}
|
|
64
101
|
}
|
|
@@ -95,25 +132,29 @@ function portalHTML() {
|
|
|
95
132
|
<style>
|
|
96
133
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
97
134
|
body {
|
|
98
|
-
font-family:
|
|
99
|
-
background: ${
|
|
100
|
-
color:
|
|
135
|
+
font-family: ${fonts.body};
|
|
136
|
+
background: ${colors.background};
|
|
137
|
+
color: ${colors.text};
|
|
101
138
|
min-height: 100vh;
|
|
102
139
|
display: flex;
|
|
103
140
|
flex-direction: column;
|
|
104
141
|
align-items: center;
|
|
105
142
|
padding: 24px 16px;
|
|
106
143
|
}
|
|
107
|
-
.logo { width:
|
|
144
|
+
.logo { width: 56px; height: 56px; margin-bottom: 12px; border-radius: 12px; }
|
|
108
145
|
.logo-text {
|
|
109
|
-
font-
|
|
110
|
-
|
|
146
|
+
font-family: ${fonts.display};
|
|
147
|
+
font-size: 28px; font-weight: 600; margin-bottom: 8px;
|
|
148
|
+
color: ${colors.primary};
|
|
149
|
+
}
|
|
150
|
+
h1 {
|
|
151
|
+
font-family: ${fonts.display};
|
|
152
|
+
font-size: 22px; font-weight: 500; margin-bottom: 4px;
|
|
111
153
|
}
|
|
112
|
-
|
|
113
|
-
.subtitle { font-size: 14px; color: #999; margin-bottom: 24px; }
|
|
154
|
+
.subtitle { font-size: 14px; color: ${colors.muted}; margin-bottom: 24px; }
|
|
114
155
|
.card {
|
|
115
|
-
background:
|
|
116
|
-
border: 1px solid
|
|
156
|
+
background: ${colors.card};
|
|
157
|
+
border: 1px solid ${colors.cardBorder};
|
|
117
158
|
border-radius: 12px;
|
|
118
159
|
width: 100%; max-width: 360px;
|
|
119
160
|
padding: 16px;
|
|
@@ -128,17 +169,17 @@ function portalHTML() {
|
|
|
128
169
|
transition: background 0.15s;
|
|
129
170
|
}
|
|
130
171
|
.network-item:hover, .network-item.selected {
|
|
131
|
-
background:
|
|
172
|
+
background: ${colors.cardBorder};
|
|
132
173
|
}
|
|
133
|
-
.network-item.selected { border: 1px solid ${
|
|
174
|
+
.network-item.selected { border: 1px solid ${colors.primary}; }
|
|
134
175
|
.network-name { font-size: 15px; font-weight: 500; }
|
|
135
|
-
.network-meta { font-size: 12px; color:
|
|
176
|
+
.network-meta { font-size: 12px; color: ${colors.muted}; }
|
|
136
177
|
.signal-bars { display: flex; gap: 2px; align-items: flex-end; height: 16px; }
|
|
137
178
|
.signal-bar {
|
|
138
179
|
width: 4px; border-radius: 1px;
|
|
139
|
-
background:
|
|
180
|
+
background: ${colors.cardBorder};
|
|
140
181
|
}
|
|
141
|
-
.signal-bar.active { background: ${
|
|
182
|
+
.signal-bar.active { background: ${colors.primary}; }
|
|
142
183
|
.password-group {
|
|
143
184
|
position: relative;
|
|
144
185
|
margin-top: 16px;
|
|
@@ -146,34 +187,35 @@ function portalHTML() {
|
|
|
146
187
|
.password-group input {
|
|
147
188
|
width: 100%;
|
|
148
189
|
padding: 12px 44px 12px 12px;
|
|
149
|
-
background:
|
|
150
|
-
border: 1px solid
|
|
190
|
+
background: ${colors.background};
|
|
191
|
+
border: 1px solid ${colors.cardBorder};
|
|
151
192
|
border-radius: 8px;
|
|
152
|
-
color:
|
|
193
|
+
color: ${colors.text};
|
|
153
194
|
font-size: 15px;
|
|
154
195
|
outline: none;
|
|
155
196
|
}
|
|
156
|
-
.password-group input:focus { border-color: ${
|
|
197
|
+
.password-group input:focus { border-color: ${colors.primary}; }
|
|
157
198
|
.toggle-pw {
|
|
158
199
|
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
|
|
159
|
-
background: none; border: none; color:
|
|
200
|
+
background: none; border: none; color: ${colors.muted}; cursor: pointer;
|
|
160
201
|
font-size: 13px; padding: 4px 8px;
|
|
161
202
|
}
|
|
162
203
|
.btn {
|
|
163
204
|
width: 100%; padding: 14px;
|
|
164
|
-
background: ${
|
|
205
|
+
background: ${colors.primary};
|
|
165
206
|
color: #fff; font-size: 16px; font-weight: 600;
|
|
166
207
|
border: none; border-radius: 8px;
|
|
167
208
|
cursor: pointer; margin-top: 16px;
|
|
168
|
-
transition:
|
|
209
|
+
transition: background 0.15s;
|
|
169
210
|
min-height: 48px;
|
|
170
211
|
}
|
|
171
|
-
.btn:disabled {
|
|
212
|
+
.btn:hover:not(:disabled) { background: ${colors.primaryHover}; }
|
|
213
|
+
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
172
214
|
.error {
|
|
173
|
-
color: #
|
|
215
|
+
color: #c0392b; font-size: 13px;
|
|
174
216
|
margin-top: 8px; display: none;
|
|
175
217
|
}
|
|
176
|
-
.empty { text-align: center; padding: 24px; color:
|
|
218
|
+
.empty { text-align: center; padding: 24px; color: ${colors.muted}; font-size: 14px; }
|
|
177
219
|
|
|
178
220
|
/* Progress / success screens */
|
|
179
221
|
.screen { display: none; width: 100%; max-width: 360px; text-align: center; }
|
|
@@ -181,31 +223,35 @@ function portalHTML() {
|
|
|
181
223
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
182
224
|
.spinner {
|
|
183
225
|
width: 40px; height: 40px;
|
|
184
|
-
border: 3px solid
|
|
185
|
-
border-top-color: ${
|
|
226
|
+
border: 3px solid ${colors.cardBorder};
|
|
227
|
+
border-top-color: ${colors.primary};
|
|
186
228
|
border-radius: 50%;
|
|
187
229
|
animation: spin 0.8s linear infinite;
|
|
188
230
|
margin: 24px auto;
|
|
189
231
|
}
|
|
190
232
|
.success-icon {
|
|
191
233
|
width: 48px; height: 48px; margin: 24px auto;
|
|
192
|
-
border-radius: 50%; background:
|
|
234
|
+
border-radius: 50%; background: ${colors.primary};
|
|
193
235
|
display: flex; align-items: center; justify-content: center;
|
|
194
236
|
font-size: 24px; color: #fff;
|
|
195
237
|
}
|
|
196
238
|
.address-box {
|
|
197
|
-
background:
|
|
198
|
-
border: 1px solid
|
|
239
|
+
background: ${colors.card};
|
|
240
|
+
border: 1px solid ${colors.cardBorder};
|
|
199
241
|
border-radius: 8px;
|
|
200
242
|
padding: 12px 16px;
|
|
201
243
|
margin: 16px auto;
|
|
202
244
|
display: inline-block;
|
|
245
|
+
max-width: 100%;
|
|
246
|
+
word-break: break-all;
|
|
203
247
|
}
|
|
204
248
|
.address-box a {
|
|
205
|
-
color: ${
|
|
206
|
-
text-decoration:
|
|
249
|
+
color: ${colors.primary};
|
|
250
|
+
text-decoration: underline;
|
|
207
251
|
font-size: 16px;
|
|
208
252
|
font-weight: 600;
|
|
253
|
+
display: block;
|
|
254
|
+
-webkit-tap-highlight-color: ${colors.primary};
|
|
209
255
|
}
|
|
210
256
|
.hint { color: #888; font-size: 13px; margin-top: 12px; line-height: 1.5; }
|
|
211
257
|
</style>
|
|
@@ -245,9 +291,11 @@ function portalHTML() {
|
|
|
245
291
|
<div class="card" style="padding:32px 16px">
|
|
246
292
|
<div class="success-icon">✓</div>
|
|
247
293
|
<h1>Connected!</h1>
|
|
248
|
-
<p class="hint">${escapedBrandName} is now online.
|
|
249
|
-
<
|
|
250
|
-
|
|
294
|
+
<p class="hint">${escapedBrandName} is now online. Redirecting in <span id="redirect-countdown">15</span>s…</p>
|
|
295
|
+
<a id="open-btn" href="#" class="btn" style="text-decoration:none;display:flex;align-items:center;justify-content:center">Open ${escapedBrandName} →</a>
|
|
296
|
+
<p class="hint" style="margin-top:16px">Or copy this URL into your browser:</p>
|
|
297
|
+
<div class="address-box" style="user-select:all" id="manual-box">
|
|
298
|
+
<span id="device-link-text"></span>
|
|
251
299
|
</div>
|
|
252
300
|
<p class="hint">This access point will close shortly.<br>Your phone will reconnect to your WiFi automatically.</p>
|
|
253
301
|
</div>
|
|
@@ -257,7 +305,10 @@ function portalHTML() {
|
|
|
257
305
|
(function() {
|
|
258
306
|
"use strict";
|
|
259
307
|
var selectedSSID = "";
|
|
260
|
-
|
|
308
|
+
// The brand service port (19200, configurable via --port), NOT the
|
|
309
|
+
// captive portal port (always 80). devicePort is appended to the
|
|
310
|
+
// post-connect URL so the link points at the admin server.
|
|
311
|
+
var devicePort = ${DEVICE_PORT === 80 ? '""' : JSON.stringify(":" + DEVICE_PORT)};
|
|
261
312
|
var deviceHostname = ${JSON.stringify(HOSTNAME)};
|
|
262
313
|
|
|
263
314
|
// Safe text insertion — no innerHTML for user-controlled content
|
|
@@ -362,13 +413,40 @@ function portalHTML() {
|
|
|
362
413
|
}
|
|
363
414
|
|
|
364
415
|
function showSuccess(data) {
|
|
365
|
-
var
|
|
416
|
+
var hostnameAddr = data.hostname
|
|
366
417
|
? "http://" + data.hostname + ".local" + devicePort
|
|
367
|
-
:
|
|
368
|
-
var
|
|
369
|
-
|
|
370
|
-
|
|
418
|
+
: null;
|
|
419
|
+
var ipAddr = data.ip ? "http://" + data.ip + devicePort : null;
|
|
420
|
+
// Auto-navigate target: prefer the IP because mDNS .local resolution
|
|
421
|
+
// is flaky on Android (notably Brave) and the redirect dead-ends.
|
|
422
|
+
var redirectAddr = ipAddr || hostnameAddr;
|
|
423
|
+
var openBtn = document.getElementById("open-btn");
|
|
424
|
+
if (openBtn) openBtn.href = redirectAddr;
|
|
425
|
+
var manualText = document.getElementById("device-link-text");
|
|
426
|
+
if (manualText) manualText.textContent = redirectAddr;
|
|
371
427
|
showScreen("success-screen");
|
|
428
|
+
// 15s countdown: the Pi keeps the AP up for ~10s after writing the
|
|
429
|
+
// success result so this poll can land, then tears it down; the
|
|
430
|
+
// phone then needs ~5s to drop the AP SSID and rejoin home WiFi.
|
|
431
|
+
var remaining = 15;
|
|
432
|
+
var countdownEl = document.getElementById("redirect-countdown");
|
|
433
|
+
var ticker = setInterval(function() {
|
|
434
|
+
remaining -= 1;
|
|
435
|
+
if (countdownEl) countdownEl.textContent = String(remaining);
|
|
436
|
+
if (remaining <= 0) {
|
|
437
|
+
clearInterval(ticker);
|
|
438
|
+
// Try multiple navigation primitives — captive webviews on some
|
|
439
|
+
// platforms ignore one but accept another.
|
|
440
|
+
try { window.location.replace(redirectAddr); } catch(e) {}
|
|
441
|
+
try { window.location.href = redirectAddr; } catch(e) {}
|
|
442
|
+
// Last-resort: synthesise a click on a freshly-built anchor.
|
|
443
|
+
try {
|
|
444
|
+
var a = document.createElement("a");
|
|
445
|
+
a.href = redirectAddr; a.rel = "noopener";
|
|
446
|
+
document.body.appendChild(a); a.click();
|
|
447
|
+
} catch(e) {}
|
|
448
|
+
}
|
|
449
|
+
}, 1000);
|
|
372
450
|
}
|
|
373
451
|
|
|
374
452
|
function pollResult() {
|
|
@@ -475,7 +553,7 @@ function handleRequest(req, res) {
|
|
|
475
553
|
|
|
476
554
|
// Captive portal detection — iOS
|
|
477
555
|
if (pathname === "/hotspot-detect.html" || pathname === "/library/test/success.html") {
|
|
478
|
-
res.writeHead(302, { Location: `http://${AP_IP}/` });
|
|
556
|
+
res.writeHead(302, { Location: `http://${PORTAL_HOST || AP_IP}/` });
|
|
479
557
|
res.end();
|
|
480
558
|
return;
|
|
481
559
|
}
|
|
@@ -483,14 +561,14 @@ function handleRequest(req, res) {
|
|
|
483
561
|
// Captive portal detection — Android
|
|
484
562
|
if (pathname === "/generate_204" || pathname === "/gen_204" ||
|
|
485
563
|
pathname === "/connecttest.txt" || pathname === "/ncsi.txt") {
|
|
486
|
-
res.writeHead(302, { Location: `http://${AP_IP}/` });
|
|
564
|
+
res.writeHead(302, { Location: `http://${PORTAL_HOST || AP_IP}/` });
|
|
487
565
|
res.end();
|
|
488
566
|
return;
|
|
489
567
|
}
|
|
490
568
|
|
|
491
569
|
// Captive portal detection — Windows
|
|
492
570
|
if (pathname === "/redirect" || pathname === "/fwlink/") {
|
|
493
|
-
res.writeHead(302, { Location: `http://${AP_IP}/` });
|
|
571
|
+
res.writeHead(302, { Location: `http://${PORTAL_HOST || AP_IP}/` });
|
|
494
572
|
res.end();
|
|
495
573
|
return;
|
|
496
574
|
}
|
|
@@ -525,6 +603,7 @@ function handleRequest(req, res) {
|
|
|
525
603
|
|
|
526
604
|
// API: POST /connect — submit WiFi credentials
|
|
527
605
|
if (pathname === "/connect" && req.method === "POST") {
|
|
606
|
+
console.error(`[wifi-provision-server] /connect POST received connecting-flag=${connecting}`);
|
|
528
607
|
if (connecting) {
|
|
529
608
|
res.writeHead(409, { "Content-Type": "application/json" });
|
|
530
609
|
res.end('{"error":"Connection already in progress"}');
|
|
@@ -577,8 +656,12 @@ function handleRequest(req, res) {
|
|
|
577
656
|
// Use async open+write to avoid blocking the event loop (a FIFO
|
|
578
657
|
// write blocks until a reader opens the other end).
|
|
579
658
|
connecting = true;
|
|
580
|
-
|
|
581
|
-
res.
|
|
659
|
+
console.error(`[wifi-provision-server] /connect ssid=${JSON.stringify(ssid)} → 200 sync-success hostname=${HOSTNAME}`);
|
|
660
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
661
|
+
res.end(JSON.stringify({
|
|
662
|
+
success: true,
|
|
663
|
+
hostname: HOSTNAME,
|
|
664
|
+
}));
|
|
582
665
|
|
|
583
666
|
const fifoData = `${ssid}\n${password}`;
|
|
584
667
|
fs.open(CONNECT_FIFO, "w", (err, fd) => {
|
|
@@ -620,8 +703,23 @@ function handleRequest(req, res) {
|
|
|
620
703
|
return;
|
|
621
704
|
}
|
|
622
705
|
|
|
623
|
-
// Default: serve portal page
|
|
624
|
-
|
|
706
|
+
// Default: serve portal page. If the client hit the raw AP IP (e.g.
|
|
707
|
+
// 192.168.4.1) and we have a friendly portal host configured, redirect
|
|
708
|
+
// so the URL bar shows the brand-aware name (e.g. http://maxy.setup/).
|
|
709
|
+
// dnsmasq's wildcard DNS resolves PORTAL_HOST → AP_IP transparently
|
|
710
|
+
// while the AP is up.
|
|
711
|
+
const incomingHost = (req.headers.host || "").split(":")[0];
|
|
712
|
+
if (PORTAL_HOST && incomingHost === AP_IP) {
|
|
713
|
+
res.writeHead(302, { Location: `http://${PORTAL_HOST}/` });
|
|
714
|
+
res.end();
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
res.writeHead(200, {
|
|
718
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
719
|
+
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
720
|
+
"Pragma": "no-cache",
|
|
721
|
+
"Expires": "0",
|
|
722
|
+
});
|
|
625
723
|
res.end(portalHTML());
|
|
626
724
|
}
|
|
627
725
|
|
|
@@ -40,15 +40,21 @@ PLATFORM_ROOT="${MAXY_PLATFORM_ROOT:-$(dirname "$SCRIPT_DIR")}"
|
|
|
40
40
|
BRAND_JSON="${PLATFORM_ROOT}/config/brand.json"
|
|
41
41
|
CONFIG_DIR=".maxy"
|
|
42
42
|
PRODUCT_NAME="Maxy"
|
|
43
|
-
HOSTNAME_NAME="maxy"
|
|
44
43
|
if [ -f "$BRAND_JSON" ] && command -v jq >/dev/null 2>&1; then
|
|
45
44
|
_dir=$(jq -r '.configDir // empty' "$BRAND_JSON" 2>/dev/null) || true
|
|
46
45
|
[ -n "$_dir" ] && CONFIG_DIR="$_dir"
|
|
47
46
|
_name=$(jq -r '.productName // empty' "$BRAND_JSON" 2>/dev/null) || true
|
|
48
47
|
[ -n "$_name" ] && PRODUCT_NAME="$_name"
|
|
49
|
-
_host=$(jq -r '.hostname // empty' "$BRAND_JSON" 2>/dev/null) || true
|
|
50
|
-
[ -n "$_host" ] && HOSTNAME_NAME="$_host"
|
|
51
48
|
fi
|
|
49
|
+
# HOSTNAME_NAME must be the **actual system hostname** (the name Avahi
|
|
50
|
+
# advertises and the post-connect URL must use), not the brand default
|
|
51
|
+
# from brand.json. The operator's --hostname flag rewrites the system
|
|
52
|
+
# hostname but never touches brand.json's hostname field, so reading
|
|
53
|
+
# from the brand mis-routed the captive portal's success URL to a name
|
|
54
|
+
# that doesn't resolve. `hostname -s` is the canonical answer Avahi
|
|
55
|
+
# itself follows when avahi-daemon.conf has `host-name` left empty,
|
|
56
|
+
# so this is the same source of truth.
|
|
57
|
+
HOSTNAME_NAME="$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo maxy)"
|
|
52
58
|
|
|
53
59
|
# Determine the home directory of the installing user. The service runs as
|
|
54
60
|
# root, but logs and config belong to the user who installed the platform.
|
|
@@ -60,9 +66,33 @@ LOG_FILE="${LOG_DIR}/wifi-provision.log"
|
|
|
60
66
|
|
|
61
67
|
mkdir -p "$LOG_DIR"
|
|
62
68
|
|
|
69
|
+
# Resolve the brand service's public port so the captive portal can render
|
|
70
|
+
# the post-connect URL with the correct port (the captive portal itself
|
|
71
|
+
# runs on :80, but the device's admin URL is on the brand port). Mirrors
|
|
72
|
+
# the same precedence as the installer: ~/{configDir}/.env override, then
|
|
73
|
+
# the systemd unit's Environment=PORT, then the documented default.
|
|
74
|
+
BRAND_PORT="19200"
|
|
75
|
+
if [ -f "${MAXY_DIR}/.env" ]; then
|
|
76
|
+
_p=$(grep -E '^PORT=' "${MAXY_DIR}/.env" 2>/dev/null | tail -1 | cut -d= -f2 | tr -d '"' | tr -d "'")
|
|
77
|
+
[ -n "$_p" ] && BRAND_PORT="$_p"
|
|
78
|
+
fi
|
|
79
|
+
if [ "$BRAND_PORT" = "19200" ]; then
|
|
80
|
+
_svc="${INSTALL_HOME}/.config/systemd/user/${HOSTNAME_NAME}.service"
|
|
81
|
+
if [ -f "$_svc" ]; then
|
|
82
|
+
_p=$(grep -E '^Environment=PORT=' "$_svc" 2>/dev/null | tail -1 | cut -d= -f3)
|
|
83
|
+
[ -n "$_p" ] && BRAND_PORT="$_p"
|
|
84
|
+
fi
|
|
85
|
+
fi
|
|
86
|
+
|
|
63
87
|
# ── Derived constants ────────────────────────────────────────────────
|
|
64
|
-
# SSID:
|
|
65
|
-
|
|
88
|
+
# SSID: lowercase the product name and replace spaces with hyphens — gives
|
|
89
|
+
# `maxy`, `real-agent`, etc. (kebab-case, no "-Setup" suffix). The user's
|
|
90
|
+
# phone-side WiFi list is the brand surface; an extra "-Setup" suffix is
|
|
91
|
+
# operator jargon, not customer language.
|
|
92
|
+
SSID="$(echo "$PRODUCT_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')"
|
|
93
|
+
# Captive-portal hostname served by dnsmasq's wildcard — gives the phone
|
|
94
|
+
# a friendly URL bar (`http://maxy.setup/`) instead of `http://192.168.4.1/`.
|
|
95
|
+
PORTAL_HOST="${SSID}.setup"
|
|
66
96
|
AP_IP="192.168.4.1"
|
|
67
97
|
AP_SUBNET="192.168.4.0/24"
|
|
68
98
|
DHCP_RANGE_START="192.168.4.2"
|
|
@@ -157,8 +187,31 @@ get_wifi_ip() {
|
|
|
157
187
|
|
|
158
188
|
scan_networks() {
|
|
159
189
|
log "scanning WiFi networks"
|
|
160
|
-
|
|
161
|
-
|
|
190
|
+
|
|
191
|
+
# `nmcli device wifi list --rescan yes` triggers a rescan but returns the
|
|
192
|
+
# currently cached list immediately — on cold boot the cache is empty
|
|
193
|
+
# because the radio hasn't completed its first scan yet. Trigger an
|
|
194
|
+
# explicit rescan, then poll the cached list until it has at least one
|
|
195
|
+
# visible (non-hidden) SSID, up to a hard timeout.
|
|
196
|
+
nmcli device wifi rescan 2>/dev/null || true
|
|
197
|
+
local raw=""
|
|
198
|
+
local elapsed=0
|
|
199
|
+
local max_wait=15
|
|
200
|
+
while [ "$elapsed" -lt "$max_wait" ]; do
|
|
201
|
+
raw=$(nmcli -t -f SSID,SIGNAL,SECURITY device wifi list 2>/dev/null) || true
|
|
202
|
+
# Lines have form SSID:SIGNAL:SECURITY; a leading ':' means hidden SSID.
|
|
203
|
+
# Break as soon as at least one line starts with a non-':' character.
|
|
204
|
+
if [ -n "$raw" ] && echo "$raw" | grep -qE '^[^:]'; then
|
|
205
|
+
break
|
|
206
|
+
fi
|
|
207
|
+
sleep 1
|
|
208
|
+
elapsed=$((elapsed + 1))
|
|
209
|
+
done
|
|
210
|
+
if [ "$elapsed" -ge "$max_wait" ]; then
|
|
211
|
+
log "scan timeout: no visible networks after ${max_wait}s — proceeding with empty list"
|
|
212
|
+
else
|
|
213
|
+
log "scan settled in ${elapsed}s"
|
|
214
|
+
fi
|
|
162
215
|
|
|
163
216
|
# Parse nmcli terse output into JSON using jq for safe escaping.
|
|
164
217
|
# Terse mode uses colon separators and escapes literal colons as \:
|
|
@@ -260,6 +313,8 @@ DNSMASQ_EOF
|
|
|
260
313
|
--result-file "$RESULT_FILE" \
|
|
261
314
|
--brand-json "$BRAND_JSON" \
|
|
262
315
|
--hostname "$HOSTNAME_NAME" \
|
|
316
|
+
--portal-host "$PORTAL_HOST" \
|
|
317
|
+
--device-port "$BRAND_PORT" \
|
|
263
318
|
>> "$LOG_FILE" 2>&1 &
|
|
264
319
|
HTTP_SERVER_PID=$!
|
|
265
320
|
sleep 1
|
|
@@ -309,10 +364,28 @@ handle_connect_requests() {
|
|
|
309
364
|
nmcli device set wlan0 managed yes 2>/dev/null
|
|
310
365
|
sleep 2
|
|
311
366
|
|
|
367
|
+
# NM's wifi-list cache is empty immediately after wlan0 is handed back
|
|
368
|
+
# from hostapd, so `nmcli device wifi connect` reports "No network with
|
|
369
|
+
# SSID 'X' found" even when hostapd's pre-AP scan saw it. Trigger a
|
|
370
|
+
# rescan and poll until the target SSID appears in the cache (or 15s
|
|
371
|
+
# timeout) before attempting the connect.
|
|
372
|
+
nmcli device wifi rescan 2>/dev/null || true
|
|
373
|
+
local rescan_elapsed=0
|
|
374
|
+
while [ "$rescan_elapsed" -lt 15 ]; do
|
|
375
|
+
if nmcli -t -f SSID device wifi list 2>/dev/null | grep -Fxq "$target_ssid"; then
|
|
376
|
+
break
|
|
377
|
+
fi
|
|
378
|
+
sleep 1
|
|
379
|
+
rescan_elapsed=$((rescan_elapsed + 1))
|
|
380
|
+
done
|
|
381
|
+
log "rescan-before-connect: target=\"${target_ssid}\" elapsed=${rescan_elapsed}s"
|
|
382
|
+
|
|
312
383
|
# Attempt WiFi connection. Capture exit code before || true so we
|
|
313
384
|
# get nmcli's actual exit status, not the unconditional 0 from || true.
|
|
314
385
|
local connect_output connect_exit
|
|
315
|
-
|
|
386
|
+
# `--wait` / `-w` is a top-level nmcli option (must precede the
|
|
387
|
+
# subcommand), not an argument to `device wifi connect`.
|
|
388
|
+
connect_output=$(nmcli --wait 30 device wifi connect "$target_ssid" password "$target_password" 2>&1)
|
|
316
389
|
connect_exit=$?
|
|
317
390
|
|
|
318
391
|
if [ $connect_exit -eq 0 ] && wifi_is_connected; then
|
|
@@ -320,6 +393,13 @@ handle_connect_requests() {
|
|
|
320
393
|
assigned_ip=$(get_wifi_ip)
|
|
321
394
|
log_state "connected" "ssid=\"${target_ssid}\" ip=\"${assigned_ip}\""
|
|
322
395
|
|
|
396
|
+
# Restart avahi-daemon so it withdraws any stale "<host>-2.local"
|
|
397
|
+
# auto-renamed records from a previous boot (where hostapd's wlan0
|
|
398
|
+
# transition triggered a self-conflict during withdraw/announce) and
|
|
399
|
+
# re-claims the canonical hostname on the freshly-connected network.
|
|
400
|
+
systemctl restart avahi-daemon 2>/dev/null || true
|
|
401
|
+
log "avahi-daemon restarted to refresh ${HOSTNAME_NAME}.local advertisement"
|
|
402
|
+
|
|
323
403
|
# Write success result for the HTTP server
|
|
324
404
|
echo "{\"success\":true,\"ip\":\"${assigned_ip}\",\"hostname\":\"${HOSTNAME_NAME}\"}" > "$RESULT_FILE"
|
|
325
405
|
|