feat: mobile setup — QR onboarding, Wi-Fi scoped mobileconfig (#73)

* fix: scope mobileconfig DNS to Wi-Fi only via OnDemandRules

Without OnDemandRules, iOS applies the DoT profile globally —
cellular DNS breaks when the phone leaves the LAN.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: phone setup QR code in dashboard header

- Add /qr endpoint serving SVG (uses existing qrcode crate, svg feature)
- Header popover: QR on desktop, direct download link on mobile viewports
- Only visible when [mobile] enabled = true in config
- Expose mobile.enabled and mobile.port in /stats response
- Lazy-load QR on first click, dismiss on outside click

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add Cache-Control to /qr, re-fetch QR on each popover open

Cache-Control: no-store prevents stale QR after LAN IP change.
Remove qrLoaded flag so the QR always reflects the current IP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: rustfmt serve_qr response tuple

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add iOS install steps to phone setup popover

iOS shows "Profile Downloaded" with no guidance. The popover
now includes the 3-step install flow including the buried
Certificate Trust Settings toggle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #73.
This commit is contained in:
Razvan Dimescu
2026-04-10 22:21:51 +03:00
committed by GitHub
parent 921ed68d54
commit 2c20c56421
8 changed files with 108 additions and 4 deletions

View File

@@ -57,6 +57,7 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
.route("/services/{name}/routes", post(add_route))
.route("/services/{name}/routes", delete(remove_route))
.route("/ca.pem", get(serve_ca))
.route("/qr", get(serve_qr))
.route("/fonts/fonts.css", get(serve_fonts_css))
.route(
"/fonts/dm-sans-latin.woff2",
@@ -170,9 +171,16 @@ struct StatsResponse {
overrides: OverrideStats,
blocking: BlockingStatsResponse,
lan: LanStatsResponse,
mobile: MobileStatsResponse,
memory: MemoryStats,
}
#[derive(Serialize)]
struct MobileStatsResponse {
enabled: bool,
port: u16,
}
#[derive(Serialize)]
struct LanStatsResponse {
enabled: bool,
@@ -551,6 +559,10 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
enabled: ctx.lan_enabled,
peers: ctx.lan_peers.lock().unwrap().list().len(),
},
mobile: MobileStatsResponse {
enabled: ctx.mobile_enabled,
port: ctx.mobile_port,
},
memory: MemoryStats {
cache_bytes,
blocklist_bytes,
@@ -931,6 +943,28 @@ pub async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResp
))
}
async fn serve_qr(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
if !ctx.mobile_enabled {
return Err(StatusCode::NOT_FOUND);
}
let lan_ip = *ctx.lan_ip.lock().unwrap();
let url = format!("http://{}:{}/mobileconfig", lan_ip, ctx.mobile_port);
let code = qrcode::QrCode::new(&url).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let svg = code
.render::<qrcode::render::svg::Color>()
.min_dimensions(180, 180)
.dark_color(qrcode::render::svg::Color("#2c2418"))
.light_color(qrcode::render::svg::Color("#faf7f2"))
.build();
Ok((
[
(header::CONTENT_TYPE, "image/svg+xml"),
(header::CACHE_CONTROL, "no-store"),
],
svg,
))
}
async fn serve_fonts_css() -> impl IntoResponse {
(
[
@@ -1005,6 +1039,8 @@ mod tests {
dnssec_strict: false,
health_meta: crate::health::HealthMeta::test_fixture(),
ca_pem: None,
mobile_enabled: false,
mobile_port: 8765,
})
}