diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml index 097805e..28eb448 100644 --- a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -222,6 +222,82 @@ Item { root.passwordSsid = "" } + // --- Captive portal --------------------------------------------------- + // NetworkManager tells "portal" (a walled-garden sign-in page sits + // between us and the real internet) apart from "full" connectivity only + // via an active probe — nmcli's cached CONNECTIVITY field just reflects + // NM's own internal check, which runs every few minutes and is far too + // slow to catch a portal right after joining a network. So this forces a + // fresh probe itself, same shell-out approach as the stats below, and + // runs it whenever we're connected — not gated to panel.visible like + // those — since the whole point is noticing this before the popup is + // ever opened. + // + // "limited" is treated the same as "portal", not just "full" vs. + // everything-else: NM only reports "portal" when the probe request + // actually comes back *different* (redirected/rewritten) from what it + // expected. Plenty of real captive portals instead black-hole every + // request that isn't to their own login domain, so the probe just times + // out — which is also why this is slow to resolve — and NM reports + // "limited" (it got a route/gateway, but the internet check itself + // never landed) rather than "portal". From the user's side both look + // identical: connected, but no real internet. + property string connectivityState: "unknown" + readonly property bool connectivityRestricted: root.connectivityState === "portal" || root.connectivityState === "limited" + + // A plain, unencrypted HTTP page whose only job is to be interceptable + // by a captive portal's redirect. An https:// URL mostly just gets + // silently blocked instead of redirected, which looks identical to the + // portal not existing at all. + readonly property string captivePortalCheckUrl: "http://neverssl.com/" + + // Auto-opened once per network join (mirrors TailscaleIndicator's + // openAuthUrl/_authUrlOpened) so flapping connectivity on the same + // network doesn't keep popping a new browser tab — the manual "Open" + // button in the panel banner below still works regardless. + property bool _portalAutoOpened: false + property bool _wasConnected: false + onConnectedDeviceChanged: { + const isConnected = root.connectedDevice !== null + if (isConnected && !root._wasConnected) { + root._portalAutoOpened = false + root.connectivityState = "unknown" + } + root._wasConnected = isConnected + } + + onConnectivityRestrictedChanged: { + if (root.connectivityRestricted && !root._portalAutoOpened) + root.openCaptivePortal() + } + + function openCaptivePortal() { + root._portalAutoOpened = true + Qt.openUrlExternally(root.captivePortalCheckUrl) + } + + Process { + id: connectivityProc + command: ["nmcli", "networking", "connectivity", "check"] + stdout: StdioCollector { + id: connectivityCollector + onStreamFinished: root.connectivityState = connectivityCollector.text.trim() + } + } + + Timer { + id: connectivityTimer + // Poll quickly while things are still unresolved (fresh join, or an + // active portal we're waiting to clear) and back off once + // connectivity is confirmed full, rather than shelling out every + // few seconds forever for no reason. + interval: root.connectivityState === "full" ? 20000 : 3000 + repeat: true + triggeredOnStart: true + running: root.connectedDevice !== null + onTriggered: if (!connectivityProc.running) connectivityProc.running = true + } + // --- Live stats -------------------------------------------------- // None of this comes from Quickshell's Networking API — it only knows // about devices/networks, not throughput, ping, or routing. So this @@ -595,6 +671,34 @@ Item { } } + // Small warning badge over the icon's corner when the connection looks + // restricted (a captive portal, confirmed or just suspected — see + // connectivityRestricted above) — same treatment as TailscaleIndicator's + // login-needed badge, so "this needs your attention" reads the same way + // across indicators. + Rectangle { + visible: root.connectivityRestricted + width: Math.max(7, label.font.pixelSize * 0.42) + height: width + radius: width / 2 + color: Colors.urgent + border.color: Colors.surface + border.width: 1 + anchors.right: label.right + anchors.bottom: label.bottom + anchors.rightMargin: -1 + anchors.bottomMargin: -1 + + Text { + anchors.centerIn: parent + text: "!" + color: Colors.surface + font.family: Appearance.fontFamily + font.pixelSize: Math.max(6, parent.height * 0.75) + font.bold: true + } + } + MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor @@ -890,6 +994,88 @@ Item { } + // Captive portal banner — shown when the connectivity probe + // above finds either a confirmed sign-in page or a + // "limited" result standing between this network and the + // real internet (see connectivityRestricted). openCaptivePortal() + // already fired once automatically when this was first + // detected; this stays up as a manual retry in case that tab + // got closed. + Rectangle { + width: parent.width + visible: root.connectivityRestricted + radius: 0 + color: Qt.rgba(Colors.urgent.r, Colors.urgent.g, Colors.urgent.b, 0.18) + border.color: Colors.urgent + border.width: 1 + implicitHeight: portalBannerRow.implicitHeight + 12 + + RowLayout { + id: portalBannerRow + anchors.fill: parent + anchors.margins: 6 + spacing: 8 + + Text { + text: "lock_open" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: Colors.urgent + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Text { + Layout.fillWidth: true + text: root.connectivityState === "portal" ? "Sign-in required" : "No internet access" + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: true + color: Colors.foreground + } + Text { + Layout.fillWidth: true + text: root.connectivityState === "portal" + ? "This network needs a web sign-in first" + : "This network may need a web sign-in first" + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + } + + Rectangle { + implicitWidth: openPortalText.implicitWidth + 16 + implicitHeight: 24 + radius: 0 + color: openPortalMouse.containsMouse ? Colors.urgent : "transparent" + border.color: Colors.urgent + border.width: 1 + + Text { + id: openPortalText + anchors.centerIn: parent + text: "Open" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: openPortalMouse.containsMouse ? Colors.primaryText : Colors.urgent + } + MouseArea { + id: openPortalMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.openCaptivePortal() + } + } + } + } + // Live stats — ping/loss/throughput/IP/gateway for whichever // device is currently connected. GridLayout { diff --git a/dotfiles/.local/bin/Plex b/dotfiles/.local/bin/Plex new file mode 100755 index 0000000..7edc79f --- /dev/null +++ b/dotfiles/.local/bin/Plex @@ -0,0 +1,13 @@ +#!/bin/sh +# Wrapper for the plex-desktop package's /usr/bin/Plex (-> /opt/plex-desktop/Plex.sh). +# +# QT_QUICK_CONTROLS_STYLE=org.hyprland.style is set globally in +# hypr/core/env.lua for other Qt/QML apps' theming (hyprland-qt-support), but +# Plex Desktop bundles its own private Qt/QML runtime that doesn't ship that +# style plugin — inheriting the var makes it fail to load its UI and exit +# immediately with "module \"org.hyprland.style\" is not installed" (see +# ~/.local/share/plex/Logs/Plex.log). This dir is first on $PATH, so it +# shadows /usr/bin/Plex for both a terminal launch and the .desktop entry +# (Exec=Plex is PATH-resolved) without touching the global env var. +unset QT_QUICK_CONTROLS_STYLE +exec /opt/plex-desktop/Plex.sh "$@" diff --git a/dotfiles/.local/share/applications/tv.plex.PlexDesktop.desktop b/dotfiles/.local/share/applications/tv.plex.PlexDesktop.desktop new file mode 100644 index 0000000..eab043c --- /dev/null +++ b/dotfiles/.local/share/applications/tv.plex.PlexDesktop.desktop @@ -0,0 +1,26 @@ +# User override of /usr/share/applications/tv.plex.PlexDesktop.desktop +# (~/.local/share/applications takes XDG precedence over /usr/share for the +# same filename, so this is picked up by app launchers instead automatically +# — no association/mimeapps changes needed). +# +# Only change from upstream: Exec unsets QT_QUICK_CONTROLS_STYLE and calls +# the package's Plex.sh directly, bypassing $PATH entirely. Plex Desktop +# bundles its own private Qt/QML runtime that doesn't ship the +# "org.hyprland.style" QQC2 style hypr/core/env.lua sets globally for other +# apps' theming — inheriting it makes Plex fail to load its UI and exit +# immediately with "module \"org.hyprland.style\" is not installed" (see +# ~/.local/share/plex/Logs/Plex.log). Going straight to the fixed install +# path (rather than relying on the .local/bin/Plex wrapper shadowing +# /usr/bin/Plex on $PATH) means this keeps working even if $PATH order ever +# changes. +[Desktop Entry] +Type=Application +Name=Plex +Comment=Plex client for desktop computers +Exec=env -u QT_QUICK_CONTROLS_STYLE /opt/plex-desktop/Plex.sh +Icon=tv.plex.PlexDesktop +Terminal=false +Categories=AudioVideo;Player;X-Media; +X-Flatpak=tv.plex.PlexDesktop +StartupWMClass=Plex +Name[en_US]=Plex diff --git a/dotfiles/.zshrc b/dotfiles/.zshrc index 8d7d17d..2dba9ec 100644 --- a/dotfiles/.zshrc +++ b/dotfiles/.zshrc @@ -51,3 +51,4 @@ export ZED_ALLOW_EMULATED_GPU=1 fastfetch eval "$(oh-my-posh init zsh --config $HOME/.config/ohmyposh/zen.toml)" +export PATH="$HOME/.local/share/turritopsis/flutter-sdk/bin:$PATH"