@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.
Files changed (26) hide show
  1. package/dist/index.js +30 -39
  2. package/package.json +1 -1
  3. package/payload/platform/plugins/docs/references/troubleshooting.md +1 -1
  4. package/payload/platform/scripts/wifi-provision-server/server.js +157 -59
  5. package/payload/platform/scripts/wifi-provision.sh +88 -8
  6. package/payload/server/chunk-FEEKJZOY.js +9841 -0
  7. package/payload/server/maxy-edge.js +80 -24
  8. package/payload/server/public/assets/{Checkbox-C_KxaLc-.js → Checkbox-DZgcVkLU.js} +1 -1
  9. package/payload/server/public/assets/{admin-xbKPR6ZI.js → admin-pSseUJFx.js} +1 -1
  10. package/payload/server/public/assets/data-Dbl98u-8.js +1 -0
  11. package/payload/server/public/assets/graph-B6QNcEdZ.js +1 -0
  12. package/payload/server/public/assets/{jsx-runtime-BZtBxBng.css → jsx-runtime-CSZKQ_0M.css} +1 -1
  13. package/payload/server/public/assets/{page-CjTfZ3O6.js → page-DUiVzuAl.js} +1 -1
  14. package/payload/server/public/assets/{page-DEWgk_nR.js → page-DzXDy3ON.js} +1 -1
  15. package/payload/server/public/assets/{public-CehiL-qZ.js → public--QCAsuXZ.js} +1 -1
  16. package/payload/server/public/assets/{share-2-BG1VXt3z.js → share-2-DHxwin_o.js} +1 -1
  17. package/payload/server/public/assets/{useVoiceRecorder-1Dvb-yHn.js → useVoiceRecorder-DNApywpF.js} +1 -1
  18. package/payload/server/public/data.html +5 -5
  19. package/payload/server/public/graph.html +6 -6
  20. package/payload/server/public/index.html +8 -8
  21. package/payload/server/public/public.html +5 -5
  22. package/payload/server/server.js +1 -1
  23. package/payload/platform/scripts/embed-backfill.sh +0 -382
  24. package/payload/server/public/assets/data-D23IzpJ2.js +0 -1
  25. package/payload/server/public/assets/graph-D2AS9zFS.js +0 -1
  26. /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
- // Tasks 787 + 788 both seed-neo4j.sh and embed-backfill.sh hard-exit
1545
- // without NEO4J_URI. The installer owns the brand-correct URI and password,
1546
- // so we derive them once and pass to both call sites. Missing password file
1547
- // is a hard error: ensureNeo4jPassword() ran upstream and would have thrown
1548
- // already if it couldn't reach the brand's Neo4j.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-realagent",
3
- "version": "1.0.775",
3
+ "version": "1.0.789",
4
4
  "description": "Install Real Agent — Built for agents. By agents.",
5
5
  "bin": {
6
6
  "create-realagent": "./dist/index.js"
@@ -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 ("{{productName}} is 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. 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.
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
- // ── Load brand config ────────────────────────────────────────────���──
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
- let brandColor = "#1a1a2e";
45
- let brandAccent = "#4a90d9";
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
- // Load logo if available (brand assets are alongside brand.json)
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
- if (brand.assets && brand.assets.icon) {
56
- const iconPath = path.join(brandDir, "assets", brand.assets.icon);
57
- if (fs.existsSync(iconPath)) {
58
- const iconData = fs.readFileSync(iconPath);
59
- const ext = path.extname(iconPath).slice(1);
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,${iconData.toString("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: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
99
- background: ${brandColor};
100
- color: #e8e8e8;
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: 48px; height: 48px; margin-bottom: 8px; border-radius: 12px; }
144
+ .logo { width: 56px; height: 56px; margin-bottom: 12px; border-radius: 12px; }
108
145
  .logo-text {
109
- font-size: 24px; font-weight: 700; margin-bottom: 8px;
110
- color: ${brandAccent};
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
- h1 { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
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: rgba(255,255,255,0.06);
116
- border: 1px solid rgba(255,255,255,0.1);
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: rgba(255,255,255,0.1);
172
+ background: ${colors.cardBorder};
132
173
  }
133
- .network-item.selected { border: 1px solid ${brandAccent}; }
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: #888; }
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: rgba(255,255,255,0.2);
180
+ background: ${colors.cardBorder};
140
181
  }
141
- .signal-bar.active { background: ${brandAccent}; }
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: rgba(255,255,255,0.08);
150
- border: 1px solid rgba(255,255,255,0.15);
190
+ background: ${colors.background};
191
+ border: 1px solid ${colors.cardBorder};
151
192
  border-radius: 8px;
152
- color: #e8e8e8;
193
+ color: ${colors.text};
153
194
  font-size: 15px;
154
195
  outline: none;
155
196
  }
156
- .password-group input:focus { border-color: ${brandAccent}; }
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: #888; cursor: pointer;
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: ${brandAccent};
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: opacity 0.15s;
209
+ transition: background 0.15s;
169
210
  min-height: 48px;
170
211
  }
171
- .btn:disabled { opacity: 0.5; cursor: not-allowed; }
212
+ .btn:hover:not(:disabled) { background: ${colors.primaryHover}; }
213
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
172
214
  .error {
173
- color: #ff6b6b; font-size: 13px;
215
+ color: #c0392b; font-size: 13px;
174
216
  margin-top: 8px; display: none;
175
217
  }
176
- .empty { text-align: center; padding: 24px; color: #888; font-size: 14px; }
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 rgba(255,255,255,0.15);
185
- border-top-color: ${brandAccent};
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: #2ecc71;
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: rgba(255,255,255,0.08);
198
- border: 1px solid rgba(255,255,255,0.15);
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: ${brandAccent};
206
- text-decoration: none;
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">&#10003;</div>
247
293
  <h1>Connected!</h1>
248
- <p class="hint">${escapedBrandName} is now online. Open your browser and visit:</p>
249
- <div class="address-box">
250
- <a id="device-link" href="#" target="_blank"></a>
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
- var devicePort = ${PORT === 80 ? '""' : JSON.stringify(":" + PORT)};
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 addr = data.hostname
416
+ var hostnameAddr = data.hostname
366
417
  ? "http://" + data.hostname + ".local" + devicePort
367
- : "http://" + data.ip + devicePort;
368
- var link = document.getElementById("device-link");
369
- link.href = addr;
370
- link.textContent = addr;
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
- res.writeHead(202, { "Content-Type": "application/json" });
581
- res.end('{"status":"connecting"}');
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
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
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: remove spaces from product name, append -Setup
65
- SSID="${PRODUCT_NAME// /}-Setup"
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
- local raw
161
- raw=$(nmcli -t -f SSID,SIGNAL,SECURITY device wifi list --rescan yes 2>/dev/null) || true
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
- connect_output=$(nmcli device wifi connect "$target_ssid" password "$target_password" --wait 30 2>&1)
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