@@ -788,6 +797,36 @@ function formatTime(epoch) {
return d.toLocaleTimeString([], { hour12: false });
}
+let qrLoaded = false;
+let mobilePort = 8765;
+function togglePhoneSetup() {
+ const pop = document.getElementById('phoneSetupPopover');
+ const isOpen = pop.style.display !== 'none';
+ pop.style.display = isOpen ? 'none' : 'block';
+ if (!isOpen && !qrLoaded) {
+ if (window.innerWidth <= 700) {
+ document.getElementById('qrContainer').style.display = 'none';
+ const linkEl = document.getElementById('phoneSetupLink');
+ const host = window.location.hostname;
+ linkEl.style.display = 'block';
+ linkEl.innerHTML = `
Install Profile`;
+ } else {
+ fetch(API + '/qr').then(r => r.text()).then(svg => {
+ document.getElementById('qrContainer').innerHTML = svg;
+ qrLoaded = true;
+ }).catch(() => {
+ document.getElementById('qrContainer').innerHTML = '
Could not load QR
';
+ });
+ }
+ }
+}
+document.addEventListener('click', (e) => {
+ const setup = document.getElementById('phoneSetup');
+ if (setup && !setup.contains(e.target)) {
+ document.getElementById('phoneSetupPopover').style.display = 'none';
+ }
+});
+
function shortSrc(addr) {
if (!addr) return '';
const ip = addr.replace(/:\d+$/, '');
@@ -1058,6 +1097,14 @@ async function refresh() {
}
}
+ const phoneSetupEl = document.getElementById('phoneSetup');
+ if (stats.mobile && stats.mobile.enabled) {
+ phoneSetupEl.style.display = '';
+ mobilePort = stats.mobile.port;
+ } else {
+ phoneSetupEl.style.display = 'none';
+ }
+
document.getElementById('overrideCount').textContent = stats.overrides.active;
document.getElementById('blockedCount').textContent = formatNumber(q.blocked);
const bl = stats.blocking;
diff --git a/src/api.rs b/src/api.rs
index fed7d5b..56e5b75 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -57,6 +57,7 @@ pub fn router(ctx: Arc
) -> 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>) -> Json {
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,22 @@ pub async fn serve_ca(State(ctx): State>) -> Result>) -> Result {
+ 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::()
+ .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")], svg))
+}
+
async fn serve_fonts_css() -> impl IntoResponse {
(
[
@@ -1005,6 +1033,8 @@ mod tests {
dnssec_strict: false,
health_meta: crate::health::HealthMeta::test_fixture(),
ca_pem: None,
+ mobile_enabled: false,
+ mobile_port: 8765,
})
}
diff --git a/src/ctx.rs b/src/ctx.rs
index cf3522d..6b774eb 100644
--- a/src/ctx.rs
+++ b/src/ctx.rs
@@ -70,6 +70,8 @@ pub struct ServerCtx {
/// Used by `/ca.pem`, `/mobileconfig`, and `/ca.mobileconfig`
/// handlers to avoid per-request disk I/O on the hot path.
pub ca_pem: Option,
+ pub mobile_enabled: bool,
+ pub mobile_port: u16,
}
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,
diff --git a/src/dot.rs b/src/dot.rs
index 32d32ba..3ed47ba 100644
--- a/src/dot.rs
+++ b/src/dot.rs
@@ -383,6 +383,8 @@ mod tests {
dnssec_strict: false,
health_meta: crate::health::HealthMeta::test_fixture(),
ca_pem: None,
+ mobile_enabled: false,
+ mobile_port: 8765,
});
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
diff --git a/src/main.rs b/src/main.rs
index 70bc3f9..62acb69 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -319,6 +319,8 @@ async fn main() -> numa::Result<()> {
dnssec_strict: config.dnssec.strict,
health_meta,
ca_pem,
+ mobile_enabled: config.mobile.enabled,
+ mobile_port: config.mobile.port,
});
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();