diff --git a/core/internal/server/network/captive_portal.go b/core/internal/server/network/captive_portal.go new file mode 100644 index 000000000..31147d1ac --- /dev/null +++ b/core/internal/server/network/captive_portal.go @@ -0,0 +1,185 @@ +package network + +import ( + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +const ( + portalProbeURL = "http://nmcheck.gnome.org/check_network_status.txt" + portalProbeExpect = "NetworkManager is online" + portalProbeTimeout = 5 * time.Second + portalProbeInterval = 30 * time.Second + portalProbeMaxBody = 4096 + portalFullProbeTicks = 10 // re-probe a healthy connection every ~5min, not every tick +) + +// portalProbe checks a known endpoint to spot a captive portal: a redirect or an +// unexpected 200 body means traffic is being intercepted. +type portalProbe struct { + mgr *Manager + client *http.Client + url string + trigger chan struct{} + stopChan chan struct{} + wg sync.WaitGroup + lastKey string + fullTicks int +} + +func newPortalProbe(m *Manager) *portalProbe { + probeURL := portalProbeURL + if v := os.Getenv("DMS_CAPTIVE_PROBE_URL"); v != "" { + probeURL = v + } + return &portalProbe{ + mgr: m, + url: probeURL, + trigger: make(chan struct{}, 1), + stopChan: make(chan struct{}), + client: &http.Client{ + Timeout: portalProbeTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } +} + +func (p *portalProbe) start() { + p.wg.Add(1) + go p.run() + p.kick() +} + +func (p *portalProbe) stop() { + close(p.stopChan) + p.wg.Wait() +} + +func (p *portalProbe) kick() { + select { + case p.trigger <- struct{}{}: + default: + } +} + +func (p *portalProbe) run() { + defer p.wg.Done() + ticker := time.NewTicker(portalProbeInterval) + defer ticker.Stop() + for { + select { + case <-p.stopChan: + return + case <-p.trigger: + p.probe(false) + case <-ticker.C: + p.probe(true) + } + } +} + +func (p *portalProbe) probe(periodic bool) { + m := p.mgr + m.stateMutex.RLock() + connected := m.state.WiFiConnected || m.state.EthernetConnected + curConn := m.state.Connectivity + key := fmt.Sprintf("%v|%s|%s", connected, m.state.WiFiSSID, m.state.EthernetIP) + m.stateMutex.RUnlock() + + if !connected { + p.lastKey = key + p.set(ConnectivityNone, "") + return + } + + if periodic { + if curConn == ConnectivityFull { + p.fullTicks++ + if p.fullTicks < portalFullProbeTicks { + return + } + p.fullTicks = 0 + } + } else if key == p.lastKey { + return + } + p.lastKey = key + + conn, loc := p.check() + p.set(conn, loc) +} + +func (p *portalProbe) check() (Connectivity, string) { + req, err := http.NewRequest(http.MethodGet, p.url, nil) + if err != nil { + return ConnectivityUnknown, "" + } + req.Header.Set("User-Agent", "DankMaterialShell/captive-portal-check") + + resp, err := p.client.Do(req) + if err != nil { + return ConnectivityNone, "" + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + return ConnectivityPortal, p.resolveLocation(resp.Header.Get("Location")) + } + if resp.StatusCode == http.StatusNoContent { + return ConnectivityFull, "" + } + // server/infra errors are not a portal + if resp.StatusCode >= 400 { + return ConnectivityUnknown, "" + } + + body, _ := io.ReadAll(io.LimitReader(resp.Body, portalProbeMaxBody)) + if resp.StatusCode == http.StatusOK && strings.Contains(string(body), portalProbeExpect) { + return ConnectivityFull, "" + } + + return ConnectivityPortal, p.url +} + +// resolveLocation turns a possibly-relative redirect target into an absolute url. +func (p *portalProbe) resolveLocation(loc string) string { + if loc == "" { + return p.url + } + base, err := url.Parse(p.url) + if err != nil { + return loc + } + ref, err := url.Parse(loc) + if err != nil { + return loc + } + return base.ResolveReference(ref).String() +} + +func (p *portalProbe) set(conn Connectivity, loc string) { + m := p.mgr + m.stateMutex.Lock() + changed := m.state.Connectivity != conn || m.state.PortalURL != loc + m.state.Connectivity = conn + m.state.PortalURL = loc + m.stateMutex.Unlock() + + if !changed { + return + } + if conn == ConnectivityPortal { + log.Infof("[captive-portal] portal detected, login url: %s", loc) + } + m.notifySubscribers() +} diff --git a/core/internal/server/network/captive_portal_test.go b/core/internal/server/network/captive_portal_test.go new file mode 100644 index 000000000..aa71cd760 --- /dev/null +++ b/core/internal/server/network/captive_portal_test.go @@ -0,0 +1,99 @@ +package network + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestPortalProbeCheck(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + wantConn Connectivity + wantURL func(srv string) string + }{ + { + name: "online when expected body returned", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(portalProbeExpect + "\n")) + }, + wantConn: ConnectivityFull, + wantURL: func(string) string { return "" }, + }, + { + name: "portal on redirect, url from location", + handler: func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://portal.example/login", http.StatusFound) + }, + wantConn: ConnectivityPortal, + wantURL: func(string) string { return "http://portal.example/login" }, + }, + { + name: "portal on unexpected 200 body", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("please sign in")) + }, + wantConn: ConnectivityPortal, + wantURL: func(srv string) string { return srv }, + }, + { + name: "relative redirect location resolved to absolute", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "/login") + w.WriteHeader(http.StatusFound) + }, + wantConn: ConnectivityPortal, + wantURL: func(srv string) string { return srv + "/login" }, + }, + { + name: "204 no content means online", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + wantConn: ConnectivityFull, + wantURL: func(string) string { return "" }, + }, + { + name: "server error is not a portal", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + }, + wantConn: ConnectivityUnknown, + wantURL: func(string) string { return "" }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(tt.handler) + defer srv.Close() + + p := newPortalProbe(nil) + p.url = srv.URL + + conn, url := p.check() + if conn != tt.wantConn { + t.Errorf("connectivity = %q, want %q", conn, tt.wantConn) + } + if want := tt.wantURL(srv.URL); url != want { + t.Errorf("url = %q, want %q", url, want) + } + }) + } +} + +func TestPortalProbeCheckUnreachable(t *testing.T) { + p := newPortalProbe(nil) + p.url = "http://127.0.0.1:1" + + conn, url := p.check() + if conn != ConnectivityNone { + t.Errorf("connectivity = %q, want %q", conn, ConnectivityNone) + } + if url != "" { + t.Errorf("url = %q, want empty", url) + } +} diff --git a/core/internal/server/network/manager.go b/core/internal/server/network/manager.go index 259b97edb..3e13679f4 100644 --- a/core/internal/server/network/manager.go +++ b/core/internal/server/network/manager.go @@ -65,6 +65,7 @@ func NewManager() (*Manager, error) { backend: backend, state: &NetworkState{ NetworkStatus: StatusDisconnected, + Connectivity: ConnectivityUnknown, Preference: PreferenceAuto, WiFiNetworks: []WiFiNetwork{}, SavedWiFiNetworks: []WiFiNetwork{}, @@ -96,6 +97,9 @@ func NewManager() (*Manager, error) { return nil, fmt.Errorf("failed to start monitoring: %w", err) } + m.portalProbe = newPortalProbe(m) + m.portalProbe.start() + return m, nil } @@ -139,6 +143,9 @@ func (m *Manager) onBackendStateChange() { if err := m.syncStateFromBackend(); err != nil { log.Errorf("failed to sync state from backend: %v", err) } + if m.portalProbe != nil { + m.portalProbe.kick() + } m.notifySubscribers() } @@ -171,6 +178,12 @@ func stateChangedMeaningfully(old, new *NetworkState) bool { if old.NetworkStatus != new.NetworkStatus { return true } + if old.Connectivity != new.Connectivity { + return true + } + if old.PortalURL != new.PortalURL { + return true + } if old.Preference != new.Preference { return true } @@ -420,6 +433,10 @@ func (m *Manager) GetPromptBroker() PromptBroker { } func (m *Manager) Close() { + if m.portalProbe != nil { + m.portalProbe.stop() + } + close(m.stopChan) m.notifierWg.Wait() diff --git a/core/internal/server/network/types.go b/core/internal/server/network/types.go index e6da82b7f..9eefdb538 100644 --- a/core/internal/server/network/types.go +++ b/core/internal/server/network/types.go @@ -24,6 +24,16 @@ const ( PreferenceEthernet ConnectionPreference = "ethernet" ) +type Connectivity string + +const ( + ConnectivityUnknown Connectivity = "unknown" + ConnectivityNone Connectivity = "none" + ConnectivityPortal Connectivity = "portal" + ConnectivityLimited Connectivity = "limited" + ConnectivityFull Connectivity = "full" +) + type WiFiNetwork struct { SSID string `json:"ssid"` BSSID string `json:"bssid"` @@ -98,6 +108,8 @@ type VPNState struct { type NetworkState struct { Backend string `json:"backend"` NetworkStatus NetworkStatus `json:"networkStatus"` + Connectivity Connectivity `json:"connectivity"` + PortalURL string `json:"portalURL"` Preference ConnectionPreference `json:"preference"` EthernetIP string `json:"ethernetIP"` EthernetDevice string `json:"ethernetDevice"` @@ -162,6 +174,7 @@ type Manager struct { notifierWg sync.WaitGroup lastNotifiedState *NetworkState credentialSubscribers syncmap.Map[string, chan CredentialPrompt] + portalProbe *portalProbe } type EventType string diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 8834e3298..1abc657ac 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -506,6 +506,7 @@ Singleton { property bool weatherEnabled: true property string networkPreference: "auto" + property bool captivePortalAutoOpen: true property string iconThemeDark: "System Default" property string iconThemeLight: "System Default" diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 23726af23..c8299a333 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -1454,4 +1454,50 @@ Item { } } } + + Loader { + id: captivePortalLoader + active: false + property string dismissedKey: "" + + function connKey() { + return NetworkService.currentWifiSSID !== "" ? "wifi:" + NetworkService.currentWifiSSID : "net:" + NetworkService.networkStatus; + } + + sourceComponent: CaptivePortalModal { + onDialogClosed: { + captivePortalLoader.dismissedKey = captivePortalLoader.connKey(); + Qt.callLater(() => captivePortalLoader.active = false); + } + Component.onCompleted: Qt.callLater(() => open()) + } + + function evaluate() { + if (NetworkService.connectivity === "full") + dismissedKey = ""; + const wantPortal = NetworkService.connectivity === "portal" && SettingsData.captivePortalAutoOpen && connKey() !== dismissedKey; + if (wantPortal) { + if (!active) + active = true; + else if (item) + item.open(); + } else if (active && item) { + item.close(); + } + } + + Connections { + target: NetworkService + function onConnectivityChanged() { + captivePortalLoader.evaluate(); + } + } + + Connections { + target: SettingsData + function onCaptivePortalAutoOpenChanged() { + captivePortalLoader.evaluate(); + } + } + } } diff --git a/quickshell/Modals/CaptivePortalModal.qml b/quickshell/Modals/CaptivePortalModal.qml new file mode 100644 index 000000000..5cfd48ab0 --- /dev/null +++ b/quickshell/Modals/CaptivePortalModal.qml @@ -0,0 +1,175 @@ +import QtQuick +import qs.Common +import qs.Modals.Common +import qs.Services +import qs.Widgets + +DankModal { + id: root + + readonly property string fallbackURL: "http://nmcheck.gnome.org/check_network_status.txt" + readonly property string ssid: NetworkService.currentWifiSSID + + function openPortal() { + const url = NetworkService.portalURL && NetworkService.portalURL.length > 0 ? NetworkService.portalURL : root.fallbackURL; + Qt.openUrlExternally(url); + root.close(); + } + + shouldBeVisible: false + allowStacking: true + useOverlayLayer: true + modalWidth: 420 + modalHeight: contentLoader.item ? contentLoader.item.implicitHeight + Theme.spacingM * 2 : 220 + + onBackgroundClicked: root.close() + + content: Component { + FocusScope { + id: portalContent + + anchors.fill: parent + focus: true + implicitHeight: mainColumn.implicitHeight + + Keys.onEscapePressed: event => { + root.close(); + event.accepted = true; + } + + Keys.onReturnPressed: event => { + root.openPortal(); + event.accepted = true; + } + + Column { + id: mainColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + anchors.topMargin: Theme.spacingM + spacing: Theme.spacingM + + Row { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: "wifi_lock" + size: Theme.iconSizeLarge + color: Theme.primary + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - Theme.iconSizeLarge - Theme.spacingM + text: I18n.tr("Sign in to network") + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + wrapMode: Text.WordWrap + } + } + + StyledText { + width: parent.width + text: root.ssid.length > 0 ? I18n.tr("The network \"%1\" requires sign-in before you can reach the internet.").arg(root.ssid) : I18n.tr("This network requires sign-in before you can reach the internet.") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + StyledText { + width: parent.width + visible: NetworkService.vpnConnected + text: I18n.tr("A VPN is active. You may need to disconnect it to reach the login page.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.warning + wrapMode: Text.WordWrap + } + + Item { + width: parent.width + height: 36 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + Rectangle { + width: Math.max(80, dismissText.contentWidth + Theme.spacingM * 2) + height: 36 + radius: Theme.cornerRadius + color: dismissArea.containsMouse ? Theme.surfaceTextHover : "transparent" + border.color: Theme.surfaceVariantAlpha + border.width: 1 + + StyledText { + id: dismissText + anchors.centerIn: parent + text: I18n.tr("Dismiss") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + font.weight: Font.Medium + } + + MouseArea { + id: dismissArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.close() + } + } + + Rectangle { + width: Math.max(120, openText.contentWidth + Theme.spacingM * 2) + height: 36 + radius: Theme.cornerRadius + color: openArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary + + StyledText { + id: openText + anchors.centerIn: parent + text: I18n.tr("Open login page") + font.pixelSize: Theme.fontSizeMedium + color: Theme.background + font.weight: Font.Medium + } + + MouseArea { + id: openArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.openPortal() + } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + } + } + } + + DankActionButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + iconName: "close" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceText + onClicked: root.close() + } + } + } +} diff --git a/quickshell/Modules/Settings/NetworkStatusTab.qml b/quickshell/Modules/Settings/NetworkStatusTab.qml index ec9c18637..d5dc091a7 100644 --- a/quickshell/Modules/Settings/NetworkStatusTab.qml +++ b/quickshell/Modules/Settings/NetworkStatusTab.qml @@ -197,6 +197,70 @@ Item { } } } + + SettingsCard { + title: I18n.tr("Captive Portal") + iconName: "wifi_lock" + settingKey: "captivePortal" + tags: ["captive", "portal", "hotspot", "sign in", "login", "public wifi"] + + width: parent.width + + Column { + width: parent.width + spacing: Theme.spacingM + + SettingsToggleRow { + width: parent.width + settingKey: "captivePortalAutoOpen" + text: I18n.tr("Open sign-in page automatically") + description: I18n.tr("Show a popup to sign in when a network requires it before reaching the internet.") + checked: SettingsData.captivePortalAutoOpen + onToggled: checked => SettingsData.set("captivePortalAutoOpen", checked) + } + + Row { + width: parent.width + spacing: Theme.spacingM + visible: NetworkService.connectivity === "portal" + + StyledText { + text: I18n.tr("Sign-in required for this network") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + width: parent.width - openPortalButton.width - Theme.spacingM + wrapMode: Text.WordWrap + } + + Rectangle { + id: openPortalButton + width: Math.max(120, openPortalLabel.contentWidth + Theme.spacingM * 2) + height: 36 + radius: Theme.cornerRadius + anchors.verticalCenter: parent.verticalCenter + color: openPortalArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary + + StyledText { + id: openPortalLabel + anchors.centerIn: parent + text: I18n.tr("Open sign-in page") + font.pixelSize: Theme.fontSizeMedium + color: Theme.background + font.weight: Font.Medium + } + + MouseArea { + id: openPortalArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Qt.openUrlExternally(NetworkService.portalURL && NetworkService.portalURL.length > 0 ? NetworkService.portalURL : "http://nmcheck.gnome.org/check_network_status.txt") + } + } + } + } + } } } } diff --git a/quickshell/Services/DMSNetworkService.qml b/quickshell/Services/DMSNetworkService.qml index 837b2d49b..e143de59c 100644 --- a/quickshell/Services/DMSNetworkService.qml +++ b/quickshell/Services/DMSNetworkService.qml @@ -14,6 +14,8 @@ Singleton { property string backend: "" property string networkStatus: "disconnected" + property string connectivity: "unknown" + property string portalURL: "" property string primaryConnection: "" property string ethernetIP: "" @@ -285,6 +287,8 @@ Singleton { backend = state.backend || ""; vpnAvailable = networkAvailable && backend === "networkmanager"; networkStatus = state.networkStatus || "disconnected"; + connectivity = state.connectivity || "unknown"; + portalURL = state.portalURL || ""; primaryConnection = state.primaryConnection || ""; ethernetIP = state.ethernetIP || ""; diff --git a/quickshell/Services/NetworkService.qml b/quickshell/Services/NetworkService.qml index 32c55c7ab..c8d06c611 100644 --- a/quickshell/Services/NetworkService.qml +++ b/quickshell/Services/NetworkService.qml @@ -12,6 +12,8 @@ Singleton { property bool networkAvailable: activeService !== null property string backend: activeService?.backend ?? "" property string networkStatus: activeService?.networkStatus ?? "disconnected" + property string connectivity: activeService?.connectivity ?? "unknown" + property string portalURL: activeService?.portalURL ?? "" property string primaryConnection: activeService?.primaryConnection ?? "" property string ethernetIP: activeService?.ethernetIP ?? "" diff --git a/quickshell/translations/en.json b/quickshell/translations/en.json index 165c04ba7..0bcca7d50 100644 --- a/quickshell/translations/en.json +++ b/quickshell/translations/en.json @@ -623,6 +623,12 @@ "reference": "Modules/Settings/AboutTab.qml:826", "comment": "" }, + { + "term": "A VPN is active. You may need to disconnect it to reach the login page.", + "context": "A VPN is active. You may need to disconnect it to reach the login page.", + "reference": "Modals/CaptivePortalModal.qml:88", + "comment": "" + }, { "term": "A file with this name already exists. Do you want to overwrite it?", "context": "A file with this name already exists. Do you want to overwrite it?", @@ -2543,6 +2549,12 @@ "reference": "Modules/Lock/LockScreenContent.qml:1200", "comment": "" }, + { + "term": "Captive Portal", + "context": "Captive Portal", + "reference": "Modules/Settings/NetworkStatusTab.qml:202", + "comment": "" + }, { "term": "Cast Target", "context": "Cast Target", @@ -4724,7 +4736,7 @@ { "term": "Dismiss", "context": "Dismiss", - "reference": "Modules/Notifications/Popup/NotificationPopup.qml:95, Modules/Notifications/Popup/NotificationPopup.qml:1452, Modules/Notifications/Center/NotificationCard.qml:835, Modules/Notifications/Center/NotificationCard.qml:975, Modules/Notifications/Center/NotificationCard.qml:1125", + "reference": "Modules/Notifications/Popup/NotificationPopup.qml:95, Modules/Notifications/Popup/NotificationPopup.qml:1452, Modules/Notifications/Center/NotificationCard.qml:835, Modules/Notifications/Center/NotificationCard.qml:975, Modules/Notifications/Center/NotificationCard.qml:1125, Modals/CaptivePortalModal.qml:114", "comment": "" }, { @@ -10739,12 +10751,30 @@ "reference": "Modals/DankLauncherV2/Controller.qml:1216", "comment": "" }, + { + "term": "Open login page", + "context": "Open login page", + "reference": "Modals/CaptivePortalModal.qml:138", + "comment": "" + }, { "term": "Open search bar to find text", "context": "Open search bar to find text", "reference": "Modules/Notepad/NotepadSettings.qml:223", "comment": "" }, + { + "term": "Open sign-in page", + "context": "Open sign-in page", + "reference": "Modules/Settings/NetworkStatusTab.qml:247", + "comment": "" + }, + { + "term": "Open sign-in page automatically", + "context": "Open sign-in page automatically", + "reference": "Modules/Settings/NetworkStatusTab.qml:212", + "comment": "" + }, { "term": "Open with...", "context": "Open with...", @@ -13967,6 +13997,12 @@ "reference": "Modules/Settings/NotificationsTab.qml:308", "comment": "" }, + { + "term": "Show a popup to sign in when a network requires it before reaching the internet.", + "context": "Show a popup to sign in when a network requires it before reaching the internet.", + "reference": "Modules/Settings/NetworkStatusTab.qml:213", + "comment": "" + }, { "term": "Show all 9 tags instead of only occupied tags", "context": "Show all 9 tags instead of only occupied tags", @@ -14225,6 +14261,18 @@ "reference": "Services/CupsService.qml:817", "comment": "" }, + { + "term": "Sign in to network", + "context": "Sign in to network", + "reference": "Modals/CaptivePortalModal.qml:69", + "comment": "" + }, + { + "term": "Sign-in required for this network", + "context": "Sign-in required for this network", + "reference": "Modules/Settings/NetworkStatusTab.qml:228", + "comment": "" + }, { "term": "Signal", "context": "Signal", @@ -15011,6 +15059,12 @@ "reference": "Modules/ControlCenter/BuiltinPlugins/CupsWidget.qml:240", "comment": "" }, + { + "term": "The network \"%1\" requires sign-in before you can reach the internet.", + "context": "The network \"%1\" requires sign-in before you can reach the internet.", + "reference": "Modals/CaptivePortalModal.qml:79", + "comment": "" + }, { "term": "The rule applies to any window matching one of these.", "context": "The rule applies to any window matching one of these.", @@ -15131,6 +15185,12 @@ "reference": "Modules/Settings/AudioTab.qml:513", "comment": "Loading overlay subtitle" }, + { + "term": "This network requires sign-in before you can reach the internet.", + "context": "This network requires sign-in before you can reach the internet.", + "reference": "Modals/CaptivePortalModal.qml:79", + "comment": "" + }, { "term": "This output is disabled in the current profile", "context": "This output is disabled in the current profile", diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 98797ba9c..d6f661fc6 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -2104,6 +2104,37 @@ "icon": "palette", "description": "Show an outline ring around the focused workspace indicator" }, + { + "section": "captivePortal", + "label": "Captive Portal", + "tabIndex": 7, + "category": "Network", + "keywords": [ + "before", + "captive", + "connection", + "connectivity", + "ethernet", + "hotspot", + "internet", + "login", + "network", + "online", + "popup", + "portal", + "public wifi", + "reaching", + "requires", + "show", + "sign", + "sign in", + "wi-fi", + "wifi", + "wireless" + ], + "icon": "wifi_lock", + "description": "Show a popup to sign in when a network requires it before reaching the internet." + }, { "section": "_tab_7", "label": "Network", @@ -2142,6 +2173,33 @@ ], "icon": "lan" }, + { + "section": "captivePortalAutoOpen", + "label": "Open sign-in page automatically", + "tabIndex": 7, + "category": "Network", + "keywords": [ + "automatically", + "before", + "connection", + "connectivity", + "ethernet", + "internet", + "network", + "online", + "open", + "page", + "popup", + "reaching", + "requires", + "show", + "sign", + "wi-fi", + "wifi", + "wireless" + ], + "description": "Show a popup to sign in when a network requires it before reaching the internet." + }, { "section": "_tab_8", "label": "Printers",