@rubytech/create-maxy 1.0.781 → 1.0.783
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/package.json
CHANGED
|
@@ -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,13 +291,13 @@ 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. Redirecting in <span id="redirect-countdown">
|
|
294
|
+
<p class="hint">${escapedBrandName} is now online. Redirecting in <span id="redirect-countdown">20</span>s…</p>
|
|
249
295
|
<div class="address-box">
|
|
250
|
-
<a id="device-link" href="#"
|
|
296
|
+
<a id="device-link" href="#"></a>
|
|
251
297
|
</div>
|
|
252
298
|
<p class="hint" id="ip-fallback" style="display:none">If the page above doesn't load (some Android browsers don't resolve <code>.local</code>), use this direct IP:</p>
|
|
253
299
|
<div class="address-box" id="ip-fallback-box" style="display:none">
|
|
254
|
-
<a id="device-link-ip" href="#"
|
|
300
|
+
<a id="device-link-ip" href="#"></a>
|
|
255
301
|
</div>
|
|
256
302
|
<p class="hint">This access point will close shortly.<br>Your phone will reconnect to your WiFi automatically.</p>
|
|
257
303
|
</div>
|
|
@@ -261,7 +307,10 @@ function portalHTML() {
|
|
|
261
307
|
(function() {
|
|
262
308
|
"use strict";
|
|
263
309
|
var selectedSSID = "";
|
|
264
|
-
|
|
310
|
+
// The brand service port (19200, configurable via --port), NOT the
|
|
311
|
+
// captive portal port (always 80). devicePort is appended to the
|
|
312
|
+
// post-connect URL so the link points at the admin server.
|
|
313
|
+
var devicePort = ${DEVICE_PORT === 80 ? '""' : JSON.stringify(":" + DEVICE_PORT)};
|
|
265
314
|
var deviceHostname = ${JSON.stringify(HOSTNAME)};
|
|
266
315
|
|
|
267
316
|
// Safe text insertion — no innerHTML for user-controlled content
|
|
@@ -370,10 +419,15 @@ function portalHTML() {
|
|
|
370
419
|
? "http://" + data.hostname + ".local" + devicePort
|
|
371
420
|
: null;
|
|
372
421
|
var ipAddr = data.ip ? "http://" + data.ip + devicePort : null;
|
|
373
|
-
|
|
422
|
+
// Display: prefer the friendly .local URL.
|
|
423
|
+
// Auto-redirect target: the raw IP. On Android (notably Brave) the
|
|
424
|
+
// mDNS .local lookup fails and the auto-navigate would dead-end;
|
|
425
|
+
// the IP works on every platform.
|
|
426
|
+
var displayAddr = hostnameAddr || ipAddr;
|
|
427
|
+
var redirectAddr = ipAddr || hostnameAddr;
|
|
374
428
|
var link = document.getElementById("device-link");
|
|
375
|
-
link.href =
|
|
376
|
-
link.textContent =
|
|
429
|
+
link.href = displayAddr;
|
|
430
|
+
link.textContent = displayAddr;
|
|
377
431
|
if (hostnameAddr && ipAddr && hostnameAddr !== ipAddr) {
|
|
378
432
|
var ipLink = document.getElementById("device-link-ip");
|
|
379
433
|
ipLink.href = ipAddr;
|
|
@@ -382,16 +436,20 @@ function portalHTML() {
|
|
|
382
436
|
document.getElementById("ip-fallback-box").style.display = "block";
|
|
383
437
|
}
|
|
384
438
|
showScreen("success-screen");
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
439
|
+
// Countdown timing: the Pi keeps the AP up for ~10s after writing the
|
|
440
|
+
// success result (so this poll can land), then tears it down. The
|
|
441
|
+
// phone then needs ~5s to drop the AP SSID and auto-rejoin its home
|
|
442
|
+
// WiFi. A 20s countdown crosses both gates so the redirect lands
|
|
443
|
+
// when the phone is back on its home network and the device is
|
|
444
|
+
// routable on its new IP.
|
|
445
|
+
var remaining = 20;
|
|
388
446
|
var countdownEl = document.getElementById("redirect-countdown");
|
|
389
447
|
var ticker = setInterval(function() {
|
|
390
448
|
remaining -= 1;
|
|
391
449
|
if (countdownEl) countdownEl.textContent = String(remaining);
|
|
392
450
|
if (remaining <= 0) {
|
|
393
451
|
clearInterval(ticker);
|
|
394
|
-
window.location.href =
|
|
452
|
+
window.location.href = redirectAddr;
|
|
395
453
|
}
|
|
396
454
|
}, 1000);
|
|
397
455
|
}
|
|
@@ -500,7 +558,7 @@ function handleRequest(req, res) {
|
|
|
500
558
|
|
|
501
559
|
// Captive portal detection — iOS
|
|
502
560
|
if (pathname === "/hotspot-detect.html" || pathname === "/library/test/success.html") {
|
|
503
|
-
res.writeHead(302, { Location: `http://${AP_IP}/` });
|
|
561
|
+
res.writeHead(302, { Location: `http://${PORTAL_HOST || AP_IP}/` });
|
|
504
562
|
res.end();
|
|
505
563
|
return;
|
|
506
564
|
}
|
|
@@ -508,14 +566,14 @@ function handleRequest(req, res) {
|
|
|
508
566
|
// Captive portal detection — Android
|
|
509
567
|
if (pathname === "/generate_204" || pathname === "/gen_204" ||
|
|
510
568
|
pathname === "/connecttest.txt" || pathname === "/ncsi.txt") {
|
|
511
|
-
res.writeHead(302, { Location: `http://${AP_IP}/` });
|
|
569
|
+
res.writeHead(302, { Location: `http://${PORTAL_HOST || AP_IP}/` });
|
|
512
570
|
res.end();
|
|
513
571
|
return;
|
|
514
572
|
}
|
|
515
573
|
|
|
516
574
|
// Captive portal detection — Windows
|
|
517
575
|
if (pathname === "/redirect" || pathname === "/fwlink/") {
|
|
518
|
-
res.writeHead(302, { Location: `http://${AP_IP}/` });
|
|
576
|
+
res.writeHead(302, { Location: `http://${PORTAL_HOST || AP_IP}/` });
|
|
519
577
|
res.end();
|
|
520
578
|
return;
|
|
521
579
|
}
|
|
@@ -645,7 +703,17 @@ function handleRequest(req, res) {
|
|
|
645
703
|
return;
|
|
646
704
|
}
|
|
647
705
|
|
|
648
|
-
// Default: serve portal page
|
|
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
|
+
}
|
|
649
717
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
650
718
|
res.end(portalHTML());
|
|
651
719
|
}
|
|
@@ -60,9 +60,33 @@ LOG_FILE="${LOG_DIR}/wifi-provision.log"
|
|
|
60
60
|
|
|
61
61
|
mkdir -p "$LOG_DIR"
|
|
62
62
|
|
|
63
|
+
# Resolve the brand service's public port so the captive portal can render
|
|
64
|
+
# the post-connect URL with the correct port (the captive portal itself
|
|
65
|
+
# runs on :80, but the device's admin URL is on the brand port). Mirrors
|
|
66
|
+
# the same precedence as the installer: ~/{configDir}/.env override, then
|
|
67
|
+
# the systemd unit's Environment=PORT, then the documented default.
|
|
68
|
+
BRAND_PORT="19200"
|
|
69
|
+
if [ -f "${MAXY_DIR}/.env" ]; then
|
|
70
|
+
_p=$(grep -E '^PORT=' "${MAXY_DIR}/.env" 2>/dev/null | tail -1 | cut -d= -f2 | tr -d '"' | tr -d "'")
|
|
71
|
+
[ -n "$_p" ] && BRAND_PORT="$_p"
|
|
72
|
+
fi
|
|
73
|
+
if [ "$BRAND_PORT" = "19200" ]; then
|
|
74
|
+
_svc="${INSTALL_HOME}/.config/systemd/user/${HOSTNAME_NAME}.service"
|
|
75
|
+
if [ -f "$_svc" ]; then
|
|
76
|
+
_p=$(grep -E '^Environment=PORT=' "$_svc" 2>/dev/null | tail -1 | cut -d= -f3)
|
|
77
|
+
[ -n "$_p" ] && BRAND_PORT="$_p"
|
|
78
|
+
fi
|
|
79
|
+
fi
|
|
80
|
+
|
|
63
81
|
# ── Derived constants ────────────────────────────────────────────────
|
|
64
|
-
# SSID:
|
|
65
|
-
|
|
82
|
+
# SSID: lowercase the product name and replace spaces with hyphens — gives
|
|
83
|
+
# `maxy`, `real-agent`, etc. (kebab-case, no "-Setup" suffix). The user's
|
|
84
|
+
# phone-side WiFi list is the brand surface; an extra "-Setup" suffix is
|
|
85
|
+
# operator jargon, not customer language.
|
|
86
|
+
SSID="$(echo "$PRODUCT_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')"
|
|
87
|
+
# Captive-portal hostname served by dnsmasq's wildcard — gives the phone
|
|
88
|
+
# a friendly URL bar (`http://maxy.setup/`) instead of `http://192.168.4.1/`.
|
|
89
|
+
PORTAL_HOST="${SSID}.setup"
|
|
66
90
|
AP_IP="192.168.4.1"
|
|
67
91
|
AP_SUBNET="192.168.4.0/24"
|
|
68
92
|
DHCP_RANGE_START="192.168.4.2"
|
|
@@ -283,6 +307,8 @@ DNSMASQ_EOF
|
|
|
283
307
|
--result-file "$RESULT_FILE" \
|
|
284
308
|
--brand-json "$BRAND_JSON" \
|
|
285
309
|
--hostname "$HOSTNAME_NAME" \
|
|
310
|
+
--portal-host "$PORTAL_HOST" \
|
|
311
|
+
--device-port "$BRAND_PORT" \
|
|
286
312
|
>> "$LOG_FILE" 2>&1 &
|
|
287
313
|
HTTP_SERVER_PID=$!
|
|
288
314
|
sleep 1
|