diff --git a/bin/omarchy-hyprland-focus-app b/bin/omarchy-hyprland-focus-app index d22d360c..08e1fa6a 100755 --- a/bin/omarchy-hyprland-focus-app +++ b/bin/omarchy-hyprland-focus-app @@ -14,10 +14,11 @@ app=${1:-} address=$( hyprctl clients -j 2>/dev/null | - jq -r --arg name "${app,,}" \ - '[.[] | select((.class // "") | ascii_downcase | startswith($name))] | first.address // empty' + jq -r --arg pattern "$app" \ + '[.[] | select((.class // "") | test($pattern; "i"))] | first.address // empty' ) [[ -n $address ]] || exit 1 -hyprctl dispatch focuswindow "address:$address" >/dev/null +hyprctl dispatch "hl.dsp.focus({ window = \"address:$address\" })" >/dev/null 2>&1 || \ + hyprctl dispatch focuswindow "address:$address" >/dev/null diff --git a/bin/omarchy-launch-browser b/bin/omarchy-launch-browser index f1076e79..cc66e7fe 100755 --- a/bin/omarchy-launch-browser +++ b/bin/omarchy-launch-browser @@ -17,3 +17,7 @@ fi systemd-run --user --quiet --collect --unit="omarchy-browser-$(date +%s%N)" \ --property=StandardOutput=null --property=StandardError=null \ uwsm-app -- "$browser_exec" "${@/--private/$private_flag}" + +if [[ -n ${HYPRLAND_INSTANCE_SIGNATURE:-} ]]; then + omarchy-hyprland-focus-app "^$(basename "$browser_exec" -stable).*$" || true +fi diff --git a/shell/plugins/panels/tailscale/Model.js b/shell/plugins/panels/tailscale/Model.js index 46089a63..41075f55 100644 --- a/shell/plugins/panels/tailscale/Model.js +++ b/shell/plugins/panels/tailscale/Model.js @@ -59,6 +59,14 @@ function accountLabel(account) { return String(account.id || "Unknown account") } +function loginPlan(needsLogin, authUrl) { + var url = String(authUrl || "").trim() + if (needsLogin === true && /^https?:\/\//.test(url)) { + return { authUrl: url, command: [] } + } + return { authUrl: "", command: ["tailscale", "up"] } +} + function peerFromStatus(id, peer) { return { id: id, @@ -270,6 +278,7 @@ if (typeof module !== "undefined") { displayHostName: displayHostName, osIcon: osIcon, accountLabel: accountLabel, + loginPlan: loginPlan, isMullvadPeer: isMullvadPeer, peerFromStatus: peerFromStatus, parseExitNodeList: parseExitNodeList, diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index 0f8f0b4f..a6e084f7 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -498,6 +498,56 @@ Panel { } } + CursorSurface { + id: connectionRow + visible: tailscale.installed && !tailscale.running + width: parent.width + implicitHeight: connectionText.implicitHeight + Style.spacing.rowPaddingX + hasCursor: root.cursorActive && root.focusSection === "header" + foreground: root.foreground + fill: root.hoverFill + + MouseArea { + anchors.fill: parent + hoverEnabled: true + enabled: !tailscale.busy + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onEntered: { + root.cursorActive = true + root.focusSection = "header" + } + onClicked: tailscale.loginOrUp() + } + + Column { + id: connectionText + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + spacing: Style.space(2) + + Text { + width: parent.width + text: tailscale.needsLogin ? "Authorize this device" : "Connect Tailscale" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.weight: Font.Medium + } + + Text { + width: parent.width + text: tailscale.needsLogin ? "Open Tailscale to restore this device's access" : "Reconnect with the current Tailscale settings" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + } + } + Text { visible: tailscale.actionStatus !== "" || tailscale.lastError !== "" width: parent.width diff --git a/shell/plugins/panels/tailscale/Service.qml b/shell/plugins/panels/tailscale/Service.qml index 820d3b48..aef8bfc3 100644 --- a/shell/plugins/panels/tailscale/Service.qml +++ b/shell/plugins/panels/tailscale/Service.qml @@ -244,15 +244,19 @@ Item { function loginOrUp() { if (!installed || loginProcess.running) return + var plan = Model.loginPlan(needsLogin, authUrl) + if (plan.authUrl !== "") { + _loginUrlOpened = false + openAuthUrlFrom(plan.authUrl, true) + return + } _loginOutput = "" _loginError = "" actionStatus = needsLogin ? "Starting Tailscale login…" : "Turning Tailscale on…" _loginInProgress = needsLogin _loginUrlOpened = false _preLoginAuthUrl = authUrl - var command = ["tailscale", "up"] - if (needsLogin) command.push("--force-reauth") - loginProcess.command = command + loginProcess.command = plan.command loginProcess.running = true if (needsLogin) loginTimeoutTimer.restart() } @@ -317,9 +321,7 @@ Item { _loginUrlOpened = true _loginInProgress = false loginTimeoutTimer.stop() - Qt.openUrlExternally(url) - actionStatus = "Opened login link" - actionStatusTimer.restart() + Quickshell.execDetached(["omarchy-launch-browser", url]) return true } return false diff --git a/test/shell.d/hyprland-focus-app-test.sh b/test/shell.d/hyprland-focus-app-test.sh new file mode 100644 index 00000000..3b998ef6 --- /dev/null +++ b/test/shell.d/hyprland-focus-app-test.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" + +cat >"$mock_bin/hyprctl" <<'SH' +#!/bin/bash +if [[ $1 == "clients" ]]; then + printf '[{"address":"0xabc","class":"chromium"}]\n' +elif [[ $1 == "dispatch" ]]; then + printf '%s\n' "$2" >"$OMARCHY_TEST_FOCUS_DISPATCH" +fi +SH +chmod +x "$mock_bin/hyprctl" + +dispatch_log="$test_tmp/dispatch" +PATH="$mock_bin:$PATH" OMARCHY_TEST_FOCUS_DISPATCH="$dispatch_log" \ + bash "$ROOT/bin/omarchy-hyprland-focus-app" chromium + +grep -F 'hl.dsp.focus({ window = "address:0xabc" })' "$dispatch_log" >/dev/null || \ + fail "app focus uses the workspace-aware Hyprland dispatcher" + +pass "app focus follows windows across workspaces" diff --git a/test/shell.d/launch-browser-test.sh b/test/shell.d/launch-browser-test.sh new file mode 100644 index 00000000..6982b908 --- /dev/null +++ b/test/shell.d/launch-browser-test.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +test_home="$test_tmp/home" +mkdir -p "$mock_bin" "$test_home/.local/share/applications" + +cat >"$test_home/.local/share/applications/chromium.desktop" <<'EOF' +[Desktop Entry] +Exec=chromium %U +EOF + +cat >"$mock_bin/xdg-settings" <<'SH' +#!/bin/bash +echo chromium.desktop +SH +cat >"$mock_bin/chromium" <<'SH' +#!/bin/bash +exit 0 +SH +cat >"$mock_bin/systemd-run" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >"$OMARCHY_TEST_BROWSER_LAUNCH" +SH +cat >"$mock_bin/omarchy-hyprland-focus-app" <<'SH' +#!/bin/bash +printf '%s\n' "$1" >"$OMARCHY_TEST_BROWSER_FOCUS" +SH +chmod +x "$mock_bin"/* + +launch_log="$test_tmp/launch" +focus_log="$test_tmp/focus" +HOME="$test_home" PATH="$mock_bin:$PATH" HYPRLAND_INSTANCE_SIGNATURE=test \ + OMARCHY_TEST_BROWSER_LAUNCH="$launch_log" OMARCHY_TEST_BROWSER_FOCUS="$focus_log" \ + bash "$ROOT/bin/omarchy-launch-browser" "https://example.test/authorize" + +grep -F 'https://example.test/authorize' "$launch_log" >/dev/null || fail "browser launcher passes through the URL" +grep -Fx '^chromium.*$' "$focus_log" >/dev/null || fail "browser launcher focuses the default browser window" + +pass "browser launcher follows opened links to the browser workspace" diff --git a/test/shell.d/tailscale-test.sh b/test/shell.d/tailscale-test.sh index 13daf429..dfb21de2 100644 --- a/test/shell.d/tailscale-test.sh +++ b/test/shell.d/tailscale-test.sh @@ -165,6 +165,22 @@ assertEqual( 'tailscale labels connections by tailnet when nickname is missing' ) +assertDeepEqual( + tailscale.loginPlan(true, 'https://login.tailscale.com/a/existing'), + { authUrl: 'https://login.tailscale.com/a/existing', command: [] }, + 'tailscale reuses the daemon authorization URL without replacing node identity' +) +assertDeepEqual( + tailscale.loginPlan(true, ''), + { authUrl: '', command: ['tailscale', 'up'] }, + 'tailscale requests a login URL when the daemon has not supplied one' +) +assertDeepEqual( + tailscale.loginPlan(false, 'https://login.tailscale.com/a/stale'), + { authUrl: '', command: ['tailscale', 'up'] }, + 'tailscale ignores stale authorization URLs outside the login state' +) + assertDeepEqual(tailscale.parseStatus('{'), { ok: false, unavailable: true, message: 'Status error', error: 'Failed to parse tailscale status' }, 'tailscale reports invalid status JSON') assertDeepEqual(tailscale.parseAccounts('{'), { accounts: [], selectedAccountId: '', selectedAccountLabel: '' }, 'tailscale handles invalid account JSON') JS