Skip to content

feat: captive portal auto-login - #67

Open
aashish-thapa wants to merge 9 commits into
mainfrom
feat/captive-portal
Open

feat: captive portal auto-login#67
aashish-thapa wants to merge 9 commits into
mainfrom
feat/captive-portal

Conversation

@aashish-thapa

@aashish-thapa aashish-thapa commented Apr 21, 2026

Copy link
Copy Markdown
Owner

Closes #61.

Summary

When NetworkManager reports Connectivity=PORTAL, wlctl now opens the probe URL in the user's browser automatically. The portal's HTTP redirect sends them to the login page.

What's included

  • PortalWatcher — tracks state transitions and a per-session SSID ignore-list so we never spam
  • launch_browser — resolves $BROWSER then falls back to xdg-open; child is fully detached
  • TUI integration: App::tick() polls the watcher and pushes a notification when a new portal is detected
  • wlctl portal subcommand — one-shot manual trigger for scripting
  • captive_portal.auto_open config toggle (default true) to disable the background behavior

Follow-ups (not in this PR)

Summary by CodeRabbit

  • New Features

    • Automatic captive-portal detection that notifies users and, when detected, attempts to open the portal URL in the browser.
    • New CLI subcommand to detect and open a captive portal on demand for one-shot checks.
  • Configuration

    • New optional setting to enable/disable automatic captive-portal detection and auto-opening (enabled by default).

@coderabbitai

coderabbitai Bot commented Apr 21, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds captive-portal detection and auto-launch: new portal module (with PortalWatcher, PortalDetected, and run_once()), exposes portal in the crate, wires a portal_watcher into App and tick(), adds a portal CLI subcommand, and a config option captive_portal.auto_open to control automatic opening.

Changes

Cohort / File(s) Summary
App Integration
src/app.rs
Added public portal_watcher: PortalWatcher; added check_captive_portal() invoked from tick() to poll for portal events, enqueue notifications, and attempt browser launches when enabled.
CLI
src/cli.rs
Added new top-level portal subcommand to the CLI.
Main Dispatch
src/main.rs
Switched to match on subcommands; added handling to run portal::run_once().await for the portal subcommand.
Configuration
src/config.rs
Added CaptivePortal struct and captive_portal: CaptivePortal field on Config with serde defaults; default auto_open: bool returns true.
Library Export
src/lib.rs
Exported new public module: pub mod portal;.
NetworkManager Client
src/nm/mod.rs
Added pub async fn get_connectivity_check_uri(&self) -> Result<String> to retrieve NM's probe URL.
Portal Engine
src/portal/mod.rs
New PortalWatcher with poll(...), PortalDetected event type, opened_for per-SSID suppression, previous_state tracking, run_once() one-shot flow, and unit tests.
Browser Launcher
src/portal/browser.rs
New pub async fn launch(url: &str) -> anyhow::Result<()> that resolves $BROWSER candidates (xdg-style) or falls back to xdg-open, spawns the command with the URL and suppresses I/O, returning spawn errors.

Sequence Diagram(s)

sequenceDiagram
    participant App
    participant PortalWatcher
    participant NMClient
    participant Notif as NotificationQueue
    participant Browser

    App->>PortalWatcher: poll(nm_client, device_path)
    PortalWatcher->>NMClient: get_connectivity_state()
    NMClient-->>PortalWatcher: Connectivity state
    alt Transition to PORTAL
        PortalWatcher->>NMClient: get_current_ssid(device_path)
        NMClient-->>PortalWatcher: SSID
        PortalWatcher->>NMClient: get_connectivity_check_uri()
        NMClient-->>PortalWatcher: probe URL
        PortalWatcher-->>App: PortalDetected{ssid, url}
        App->>App: check config.captive_portal.auto_open
        alt auto_open enabled
            App->>Notif: enqueue info (ttl=5)
            App->>Browser: launch_browser(url)
            Browser-->>App: Ok / Err
            alt Browser Err
                App->>Notif: enqueue error (ttl=5)
            end
        end
    else No portal / unchanged
        PortalWatcher-->>App: None
    end
Loading
sequenceDiagram
    participant User
    participant CLI as wlctl portal
    participant Portal as portal::run_once()
    participant NMClient
    participant Browser

    User->>CLI: invoke `wlctl portal`
    CLI->>Portal: run_once()
    Portal->>NMClient: check connectivity
    NMClient-->>Portal: Connectivity result
    Portal->>User: print status
    alt Connectivity == PORTAL
        Portal->>NMClient: get_connectivity_check_uri()
        NMClient-->>Portal: probe URL
        alt URL non-empty
            Portal->>Browser: launch(url)
            Browser-->>Portal: Ok / Err
        end
    end
    Portal-->>CLI: return
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hopped through NetworkManager's trail,
Spotted portals where connections fail,
I nudged a browser, opened the gate,
Remembered SSIDs to ease your fate,
A tiny hop to streamline your Wi‑Fi tale.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: captive portal auto-login' clearly summarizes the main change: adding automatic login for captive portals.
Linked Issues check ✅ Passed The PR implements the core requirement from #61: detecting portal state and auto-opening browser. Per-SSID credential caching is deferred as a future enhancement.
Out of Scope Changes check ✅ Passed All changes align with #61 objectives: portal detection, browser launch, TUI integration, CLI subcommand, and config toggle for auto-open behavior.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/captive-portal

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/app.rs (1)

165-173: Don’t silently drop portal polling errors.

Line 172 discards all poll failures, which makes auto-open regressions hard to debug. Consider logging at debug/warn while keeping this path non-fatal.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app.rs` around lines 165 - 173, The match on portal_watcher.poll
currently swallows all errors (Err(_)) silently; change the Err branch to log
the error (including the error value) at debug or warn level rather than
returning silently so failures in portal_watcher.poll(&self.client,
&self.device.device_path).await are visible; keep the non-fatal behavior (still
return after logging) and include context (e.g., the device path and that
polling failed) to aid debugging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/portal/browser.rs`:
- Around line 15-18: The current env::var("BROWSER") handling treats the whole
BROWSER string as a single executable which fails if it contains multiple
candidates, colons or embedded args; change the logic that builds (program,
args) so it (1) splits BROWSER on ':' into candidates and tries them in order,
(2) for a chosen candidate splits on whitespace to treat the first token as the
executable and the rest as initial args, and (3) appends the URL to those args;
if none of the candidates spawn successfully fall back to "xdg-open" with the
URL. Update the same parsing used later around the other BROWSER handling (lines
20-27) so both places use this candidate-and-token-splitting behavior instead of
passing the entire BROWSER string as the program.

In `@src/portal/mod.rs`:
- Around line 49-70: previous_state and opened_for are being mutated before
confirming the SSID and connectivity URI, which can permanently suppress portal
handling on transient lookup failures; move the mutation logic so you only call
self.previous_state.replace(state) and self.opened_for.insert(ssid.clone())
after nm.get_connected_ssid(...) and nm.get_connectivity_check_uri() both
succeed and url is non-empty. Concretely: call
nm.get_connected_ssid(device_path).await and
nm.get_connectivity_check_uri().await first, bail early on failures or empty
URI, then perform the previous_state update (replace) and the opened_for.insert
only when you know you have a valid ssid and url, using the same identifiers
previous_state and opened_for to find the correct spots to move these calls.

---

Nitpick comments:
In `@src/app.rs`:
- Around line 165-173: The match on portal_watcher.poll currently swallows all
errors (Err(_)) silently; change the Err branch to log the error (including the
error value) at debug or warn level rather than returning silently so failures
in portal_watcher.poll(&self.client, &self.device.device_path).await are
visible; keep the non-fatal behavior (still return after logging) and include
context (e.g., the device path and that polling failed) to aid debugging.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: abf41b02-4412-4bc5-843d-83d15cd8b465

📥 Commits

Reviewing files that changed from the base of the PR and between 0e3b06a and 2b376ad.

📒 Files selected for processing (8)
  • src/app.rs
  • src/cli.rs
  • src/config.rs
  • src/lib.rs
  • src/main.rs
  • src/nm/mod.rs
  • src/portal/browser.rs
  • src/portal/mod.rs

Comment thread src/portal/browser.rs Outdated
Comment thread src/portal/mod.rs Outdated
…transient errors

- resolve_command splits $BROWSER on colons and takes the first non-empty
  trimmed candidate, then whitespace-splits for program + args. Handles
  BROWSER="firefox --private-window" and BROWSER="firefox:chromium"
  correctly; previously the whole string was treated as a single
  executable name, breaking auto-open silently
- PortalWatcher::poll now commits previous_state and opened_for only
  after SSID + URL lookups succeed. A transient D-Bus failure previously
  marked the portal transition as already handled, making auto-open
  never fire again for that session
- URL lookup uses ? instead of unwrap_or_default() so genuine errors
  surface rather than masquerading as "no URL configured"
- 6 unit tests cover resolve_command edge cases (unset, empty,
  whitespace, plain, args, colon fallbacks, leading empties)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/portal/mod.rs (2)

71-74: Differentiate SSID absence from SSID lookup failure.

Line 71 currently treats Ok(None) and Err(_) the same (Ok(None)), which can hide persistent lookup failures and make the feature fail silently.

Proposed adjustment
-        let ssid = match nm.get_connected_ssid(device_path).await {
-            Ok(Some(s)) => s,
-            _ => return Ok(None),
-        };
+        let ssid = match nm.get_connected_ssid(device_path).await {
+            Ok(Some(s)) => s,
+            Ok(None) => return Ok(None),
+            Err(e) => return Err(e.into()),
+        };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/portal/mod.rs` around lines 71 - 74, The current match on
nm.get_connected_ssid(device_path).await conflates Ok(None) and Err(_) by
returning Ok(None); change it so Ok(Some(s)) assigns ssid, Ok(None) still
returns Ok(None) (SSID not present), but Err(e) is propagated as an error
(return Err(e) or map the error into the function's error type) so lookup
failures are not swallowed; ensure the surrounding function's Result error type
supports returning that error from get_connected_ssid.

151-167: Tests should cover PortalWatcher::poll() behavior, not just data structures.

Current tests are useful but don’t guard the transition/error semantics that this PR depends on (portal transition, transient lookup failure retry, per-SSID suppression, empty URL handling).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/portal/mod.rs` around lines 151 - 167, Add unit tests that exercise
PortalWatcher::poll() behavior rather than only structs: write tests that use
the existing watcher_with helper (or create a similar mocked PortalWatcher) to
simulate (1) a successful portal detection sequence and verify state transition
and emitted PortalDetected (ssid and url) instead of just PortalDetected
construction, (2) a transient DNS/lookup failure followed by success to ensure
poll() retries and ultimately emits the portal event, (3) per-SSID suppression
by calling poll() twice for the same ssid and asserting only the first poll
emits PortalDetected (use opened_for to verify suppression), and (4) handling of
an empty URL response by asserting poll() does not emit a PortalDetected and
updates state appropriately; target functions/types PortalWatcher::poll,
watcher_with, opened_for, and PortalDetected when adding these tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/portal/mod.rs`:
- Around line 71-74: The current match on
nm.get_connected_ssid(device_path).await conflates Ok(None) and Err(_) by
returning Ok(None); change it so Ok(Some(s)) assigns ssid, Ok(None) still
returns Ok(None) (SSID not present), but Err(e) is propagated as an error
(return Err(e) or map the error into the function's error type) so lookup
failures are not swallowed; ensure the surrounding function's Result error type
supports returning that error from get_connected_ssid.
- Around line 151-167: Add unit tests that exercise PortalWatcher::poll()
behavior rather than only structs: write tests that use the existing
watcher_with helper (or create a similar mocked PortalWatcher) to simulate (1) a
successful portal detection sequence and verify state transition and emitted
PortalDetected (ssid and url) instead of just PortalDetected construction, (2) a
transient DNS/lookup failure followed by success to ensure poll() retries and
ultimately emits the portal event, (3) per-SSID suppression by calling poll()
twice for the same ssid and asserting only the first poll emits PortalDetected
(use opened_for to verify suppression), and (4) handling of an empty URL
response by asserting poll() does not emit a PortalDetected and updates state
appropriately; target functions/types PortalWatcher::poll, watcher_with,
opened_for, and PortalDetected when adding these tests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 57c0d5c8-fff6-4ab9-8049-90fbc85cd2eb

📥 Commits

Reviewing files that changed from the base of the PR and between 2b376ad and a7795f4.

📒 Files selected for processing (2)
  • src/portal/browser.rs
  • src/portal/mod.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/portal/browser.rs

get_connected_ssid returns Result<Option<String>>. The previous match
collapsed both `Err(_)` (D-Bus failure) and `Ok(None)` (no associated
SSID) into a silent early-return, hiding transient lookup failures.

Use `?` to propagate errors (consistent with the sibling URL lookup
three lines below) and `let-else` on the `Option` to keep the "no SSID
currently" no-op explicit. Caller behaviour in app.rs is unchanged —
both paths still result in no notification — but the error now exists
at the API boundary for future logging.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/portal/mod.rs (2)

90-93: opened_for is recorded before launch success is known.

At Line 92, the SSID is marked handled before the caller performs browser launch. A transient launch failure can suppress further auto-open attempts for that SSID during the session.

💡 Suggested direction
 pub struct PortalWatcher {
     previous_state: Option<Connectivity>,
     opened_for: HashSet<String>,
 }
 
 impl PortalWatcher {
+    pub fn mark_opened(&mut self, ssid: &str) {
+        self.opened_for.insert(ssid.to_string());
+    }
+
     pub async fn poll(
         &mut self,
         nm: &Arc<NMClient>,
         device_path: &str,
     ) -> Result<Option<PortalDetected>> {
@@
-        self.opened_for.insert(ssid.clone());
         Ok(Some(PortalDetected { ssid, url }))
     }
 }

Then call mark_opened() only after launch_browser() succeeds.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/portal/mod.rs` around lines 90 - 93, The SSID is being recorded as
handled too early — move the mutation of self.opened_for (or call to
mark_opened()) so it only happens after a successful browser launch;
specifically, after launch_browser() returns without error, set
self.previous_state = Some(Connectivity::Portal) and then insert the SSID into
opened_for (or call mark_opened(ssid)) so transient launch failures won't
suppress future auto-open attempts for that SSID during the session.

152-168: Tests currently miss poll() transition regressions.

These tests validate data structures but not watcher behavior (transition gating, SSID suppression, transient lookup failures). Adding poll()-focused tests would guard the core portal logic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/portal/mod.rs` around lines 152 - 168, Add unit tests that exercise the
watcher's poll() transition logic rather than only struct behavior: create tests
using watcher_with(...) and PortalDetected to simulate detection sequences and
call w.poll() between events to assert state transitions (e.g., gating from
Connectivity states, that opened_for set suppresses repeated PortalDetected for
the same SSID, and that transient lookup failures (simulate lookup error) do not
permanently mark SSIDs as opened). Specifically add tests that (1) send repeated
PortalDetected("work") events with intermediate w.poll() and assert only the
first triggers an "open" path (opened_for behavior), (2) simulate a lookup
failure then a success around poll() to verify the watcher retries/transient
behavior, and (3) verify connectivity-based gating by toggling Connectivity
variants and calling poll() to ensure transitions occur as expected; reference
watcher_with, poll(), opened_for, and PortalDetected when locating where to add
these tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/portal/mod.rs`:
- Around line 63-66: The early return skips SSID checks whenever
self.previous_state == Some(Connectivity::Portal), causing missed Portal→Portal
SSID changes; update the logic in the function containing the return so it only
short-circuits when the prior SSID is the same as the current one (e.g., check
self.previous_ssid == current_ssid) or remove the return and let downstream SSID
comparison logic run; reference self.previous_state, self.previous_ssid and
Connectivity::Portal to locate and adjust the condition so Portal→Portal
transitions with different SSIDs are detected.

---

Nitpick comments:
In `@src/portal/mod.rs`:
- Around line 90-93: The SSID is being recorded as handled too early — move the
mutation of self.opened_for (or call to mark_opened()) so it only happens after
a successful browser launch; specifically, after launch_browser() returns
without error, set self.previous_state = Some(Connectivity::Portal) and then
insert the SSID into opened_for (or call mark_opened(ssid)) so transient launch
failures won't suppress future auto-open attempts for that SSID during the
session.
- Around line 152-168: Add unit tests that exercise the watcher's poll()
transition logic rather than only struct behavior: create tests using
watcher_with(...) and PortalDetected to simulate detection sequences and call
w.poll() between events to assert state transitions (e.g., gating from
Connectivity states, that opened_for set suppresses repeated PortalDetected for
the same SSID, and that transient lookup failures (simulate lookup error) do not
permanently mark SSIDs as opened). Specifically add tests that (1) send repeated
PortalDetected("work") events with intermediate w.poll() and assert only the
first triggers an "open" path (opened_for behavior), (2) simulate a lookup
failure then a success around poll() to verify the watcher retries/transient
behavior, and (3) verify connectivity-based gating by toggling Connectivity
variants and calling poll() to ensure transitions occur as expected; reference
watcher_with, poll(), opened_for, and PortalDetected when locating where to add
these tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 09d98cea-3f22-4174-944f-0e16935e1735

📥 Commits

Reviewing files that changed from the base of the PR and between a7795f4 and 1f0b7d3.

📒 Files selected for processing (1)
  • src/portal/mod.rs

Comment thread src/portal/mod.rs
Comment on lines +63 to +66
// Already in Portal on the previous tick — no transition to report.
if self.previous_state == Some(Connectivity::Portal) {
return Ok(None);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Portal→Portal SSID switches can be missed.

The early return at Line 64 skips SSID evaluation whenever the prior tick was Portal. If a user moves from one captive SSID to another without an observed non-Portal tick, the second network may never trigger detection.

💡 Suggested fix
-        // Already in Portal on the previous tick — no transition to report.
-        if self.previous_state == Some(Connectivity::Portal) {
-            return Ok(None);
-        }
+        // Even if we were already in Portal, continue to SSID evaluation so
+        // portal→portal SSID switches are not missed.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/portal/mod.rs` around lines 63 - 66, The early return skips SSID checks
whenever self.previous_state == Some(Connectivity::Portal), causing missed
Portal→Portal SSID changes; update the logic in the function containing the
return so it only short-circuits when the prior SSID is the same as the current
one (e.g., check self.previous_ssid == current_ssid) or remove the return and
let downstream SSID comparison logic run; reference self.previous_state,
self.previous_ssid and Connectivity::Portal to locate and adjust the condition
so Portal→Portal transitions with different SSIDs are detected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feature-request] Auto-login for captive portals (hotel/airport WiFi)

1 participant