Files
omarchycn/shell/plugins/panels/tailscale/Service.qml
T
e57f3b286c Send and receive files with Taildrop (#6375)
* Send files to a tailnet machine with Taildrop

The panel gets a send button next to the copy one on every machine that
Tailscale grades as a Taildrop target, and `s` does the same from the
keyboard. Picking runs through the XDG portal chooser, so it looks like
the file dialog every other app opens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJQJfHXXApUk6En8EZisHg

* Save incoming Taildrop files and say so

Linux keeps Taildrop files in the daemon's inbox until someone asks for
them, so nothing arrived until you ran `tailscale file get` by hand. A
user service now stages each delivery next to the downloads directory,
hands it over under a free name, and announces it — with a preview when
it's an image, and a click to open it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJQJfHXXApUk6En8EZisHg

* Float every portal dialog, not just the titled ones

The portal only ever shows dialogs, and the title regex missed any
chooser an app names something else — ours included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJQJfHXXApUk6En8EZisHg

* Re-run the Taildrop enable now that the unit ships

The unit was never installed to /usr/lib/systemd/user/, so the enable had
nothing to act on and machines that already ran the migration carry a marker
for a no-op. Rename it so they get a working pass, and report what systemctl
says instead of a bare failure line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Wait for the file chooser on the connection that asked for it

The portal answers a request with a Response signal directed at the connection
that made it, and dbus-daemon delivers directed signals only to that
connection. gdbus monitor registers with AddMatch rather than BecomeMonitor, so
it never saw the reply: every pick left omarchy-file-select blocked on a read
that could not arrive, taking omarchy-tailscale-send down with it before it
reached either its notification or the transfer.

Make the call and wait for the signal on one connection, and give up after ten
minutes so an unanswered dialog cannot strand the caller.

Drop the "Sending to" notification while here, so a send reports once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Mark Taildrop notifications with the panel's send glyph

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Stop a hung tailscale poll from freezing the panel

Each poll is skipped while its own process is still running, so one that never
exits leaves the panel showing whatever it last read, for good: the peer list
keeps a woken machine missing, and opening the panel cannot help because open
runs the same refresh that hits the same guard.

Reap anything still running fifteen seconds after a refresh, well inside the
thirty second interval, so the next tick starts clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Place a moved widget where an added one lands

'move omarchy.media left' named a section, not a slot, but the section went
through as an explicit target, which resolves a missing index by appending. The
widget landed on the far end of the row instead of after the section anchor
where 'add' puts it.

Its test has never run: the assertion covering this went in four hours after an
unrelated layout change had already stopped the file, and the runner stops the
whole suite at the first failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Keep the config test from failing on things it is not about

The center layout assertion pinned the whole row, so parking the indicators
left of the clock broke a test named for update sitting next to weather. Assert
that adjacency instead.

The package-defaults check reads PKGBUILDs from the omarchy-pkgs repo and blew
up with a traceback wherever that is not a sibling checkout. Skip it when the
checkout is absent, honour OMARCHY_PKGS_ROOT when it is somewhere else, and
keep failing when it is present and wrong.

Between them these stopped the suite eighty files early.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Make the file chooser a Python command rather than a bash host for one

The portal work was a heredoc wedged inside a bash script that existed only to
parse two flags. Drop the host: argparse covers the flags, and the file says at
the top why it is the one command here not written in bash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Tell a chooser that never opened apart from one that was dismissed

Three fixes from review:

The poll watchdog rearmed on every refresh, so a refresh interval shorter than
its timeout — the setting goes down to five seconds — pushed the deadline ahead
of a hung process forever. Arm it on the launch that needs watching and leave
it alone.

omarchy-file-select exited 1 both for nothing picked and for a chooser that
could not run, and omarchy-tailscale-send read it through a process
substitution, which drops the status anyway. A session bus that was not there
looked exactly like someone changing their mind. Separate the two exits and
read them with a command substitution.

Delivery picked a free name and then renamed, which overwrites anything that
takes the name in between. Link to the name instead: link(2) refuses one that
is taken, so the check and the claim are the same step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:07:11 -07:00

638 lines
21 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import "Model.js" as Model
Item {
id: root
property var settings: ({})
property bool installed: false
property bool running: false
property bool needsLogin: false
// Optimistic off state so the UI reacts the instant you click, rather than
// waiting for the next status refresh. _desired is -1 while we just follow
// the real state, or 0/1 while a toggle is still catching up.
property int _desired: -1
readonly property bool active: _desired === -1 ? running : (_desired === 1)
property bool refreshing: false
property string backendState: "Unknown"
property string statusText: "Checking…"
property string selfName: ""
property string selfDnsName: ""
property string selfIp: ""
property string selfUserId: ""
property bool fileSharing: false
property string authUrl: ""
property var peers: []
property var exitNodes: []
property var tailnetExitNodes: []
property var mullvadExitNodes: []
property var mullvadRegions: []
property var accounts: []
property string selectedAccountId: ""
property string selectedAccountLabel: ""
property string switchingAccountId: ""
property string settingExitNodeId: ""
property bool accountsAccessDenied: false
property string actionStatus: ""
property string lastError: ""
readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 30, 5, 3600)
readonly property bool busy: whichProcess.running || statusProcess.running || mullvadExitNodesProcess.running || accountsProcess.running || actionProcess.running || loginProcess.running || switchProcess.running || operatorProcess.running || exitNodeProcess.running
readonly property string userName: Quickshell.env("USER") || Quickshell.env("LOGNAME")
property string _statusOutput: ""
property string _statusError: ""
property string _accountsOutput: ""
property string _accountsError: ""
property string _mullvadExitNodesOutput: ""
property string _mullvadExitNodesError: ""
property string _actionOutput: ""
property string _actionError: ""
property string _loginOutput: ""
property string _loginError: ""
property bool _loginInProgress: false
property bool _loginUrlOpened: false
property string _preLoginAuthUrl: ""
property double _lastAccountsRefreshMs: 0
property string _switchOutput: ""
property string _switchError: ""
property string _exitNodeOutput: ""
property string _exitNodeError: ""
property string _operatorOutput: ""
property string _operatorError: ""
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
function intSetting(name, fallback, min, max) {
var n = parseInt(String(setting(name, fallback)), 10)
if (!isFinite(n)) n = fallback
if (n < min) n = min
if (n > max) n = max
return n
}
function filterIPv4(ips) {
return Model.filterIPv4(ips)
}
function cleanDnsName(name) {
return Model.cleanDnsName(name)
}
function shortDnsName(name) {
return Model.shortDnsName(name)
}
function displayHostName(hostName, dnsName) {
return Model.displayHostName(hostName, dnsName)
}
function osIcon(os) {
return Model.osIcon(os)
}
function accountLabel(account) {
return Model.accountLabel(account)
}
function copyToClipboard(value, label) {
var text = String(value || "")
if (text === "") return
Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(text) + " | wl-copy"])
}
function copyPeerIp(peer) {
if (!peer) return
var ips = filterIPv4(peer.TailscaleIPs || [])
copyToClipboard(ips.length > 0 ? ips[0] : "", displayHostName(peer.HostName, peer.DNSName) + " IP")
}
function copyPeerName(peer) {
if (!peer) return
copyToClipboard(displayHostName(peer.HostName, peer.DNSName), displayHostName(peer.HostName, peer.DNSName) + " name")
}
function copyPeerDnsName(peer) {
if (!peer) return
copyToClipboard(cleanDnsName(peer.DNSName), displayHostName(peer.HostName, peer.DNSName) + " DNS name")
}
function peerAddress(peer) {
if (!peer) return ""
if (peer.DNSName) return cleanDnsName(peer.DNSName)
if (peer.HostName) return String(peer.HostName)
var ips = filterIPv4(peer.TailscaleIPs || [])
return ips.length > 0 ? ips[0] : ""
}
function canSendFiles(peer) {
if (!fileSharing || !running || !peer) return false
return Model.isTaildropTarget(peer, selfUserId)
}
function sendFile(peer) {
if (!canSendFiles(peer)) return
var target = peerAddress(peer)
if (target === "") return
Quickshell.execDetached(["omarchy-tailscale-send", target])
}
function refresh(forceAccounts) {
if (installed) {
refreshStatusAndAccounts(forceAccounts === true)
return
}
if (!whichProcess.running) {
refreshing = true
whichProcess.command = ["which", "tailscale"]
whichProcess.running = true
}
}
function refreshStatusAndAccounts(forceAccounts) {
if (!installed) return
var launched = false
if (!statusProcess.running) {
_statusOutput = ""
_statusError = ""
refreshing = true
statusProcess.command = ["tailscale", "status", "--json"]
statusProcess.running = true
launched = true
}
if (!mullvadExitNodesProcess.running) {
_mullvadExitNodesOutput = ""
_mullvadExitNodesError = ""
mullvadExitNodesProcess.command = ["tailscale", "exit-node", "list"]
mullvadExitNodesProcess.running = true
launched = true
}
var now = Date.now()
var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000
if (shouldRefreshAccounts && !accountsProcess.running) {
_accountsOutput = ""
_accountsError = ""
_lastAccountsRefreshMs = now
accountsProcess.command = ["tailscale", "switch", "--list", "--json"]
accountsProcess.running = true
launched = true
}
// Arm on the launch that needs watching and leave it alone after that.
// Restarting it every refresh pushes the deadline out ahead of a hung
// process forever once the refresh interval is shorter than the timeout,
// and refreshIntervalSec goes down to five seconds.
if (launched && !pollWatchdog.running) pollWatchdog.start()
}
function elideStatus(text) {
var value = String(text || "").replace(/\s+/g, " ").trim()
return value.length > 140 ? value.substring(0, 137) + "…" : value
}
function resetUnavailable(message) {
running = false
needsLogin = false
_desired = -1
backendState = "Unavailable"
statusText = message
selfName = ""
selfDnsName = ""
selfIp = ""
selfUserId = ""
fileSharing = false
authUrl = ""
peers = []
exitNodes = []
tailnetExitNodes = []
mullvadExitNodes = []
mullvadRegions = []
accounts = []
selectedAccountId = ""
selectedAccountLabel = ""
switchingAccountId = ""
settingExitNodeId = ""
accountsAccessDenied = false
}
function parseStatus(raw) {
var parsed = Model.parseStatus(raw)
if (!parsed.ok) {
resetUnavailable(parsed.message || "Status error")
lastError = parsed.error || "Failed to parse tailscale status"
console.warn("tailscale", lastError)
return
}
if (parsed.unavailable) {
resetUnavailable(parsed.message || "Disconnected")
return
}
backendState = parsed.backendState
running = parsed.running
// Reality caught up to the pending toggle — stop overriding.
if (_desired !== -1 && running === (_desired === 1)) _desired = -1
needsLogin = parsed.needsLogin
authUrl = parsed.authUrl
if (needsLogin && _loginInProgress && !_loginUrlOpened && authUrl !== "" && authUrl !== _preLoginAuthUrl) openAuthUrlFrom(authUrl, false)
selfName = parsed.selfName
selfDnsName = parsed.selfDnsName
selfIp = parsed.selfIp
selfUserId = parsed.selfUserId
fileSharing = parsed.fileSharing
peers = parsed.running ? parsed.peers : []
tailnetExitNodes = parsed.running ? parsed.exitNodes : []
exitNodes = parsed.running ? tailnetExitNodes.concat(mullvadRegions) : []
if (needsLogin) statusText = "Needs login"
else if (running) {
statusText = "Connected"
_loginInProgress = false
_loginUrlOpened = false
_preLoginAuthUrl = ""
loginTimeoutTimer.stop()
} else if (backendState === "Stopped") {
statusText = "Disconnected"
} else {
statusText = backendState
}
lastError = ""
}
function parseAccounts(raw) {
var parsed = Model.parseAccounts(raw)
accounts = parsed.accounts
selectedAccountId = parsed.selectedAccountId
selectedAccountLabel = parsed.selectedAccountLabel
accountsAccessDenied = false
}
function parseMullvadExitNodes(raw) {
mullvadExitNodes = Model.parseExitNodeList(raw)
mullvadRegions = Model.mullvadRegionOptions(mullvadExitNodes)
exitNodes = running ? tailnetExitNodes.concat(mullvadRegions) : []
}
function toggleTailscale() {
if (!installed) return
if (active) down()
else loginOrUp()
}
function down() {
// No progress status here — the greyed icon and hero line already convey
// the optimistic off; only surface a message if the command fails.
_desired = 0
runAction(["tailscale", "down"])
}
function loginOrUp() {
if (!installed || loginProcess.running) return
_desired = -1
var plan = Model.loginPlan(needsLogin, authUrl)
if (plan.authUrl !== "") {
_loginUrlOpened = false
openAuthUrlFrom(plan.authUrl, true)
return
}
_loginOutput = ""
_loginError = ""
if (needsLogin) actionStatus = "Starting Tailscale login…"
else _desired = 1
_loginInProgress = needsLogin
_loginUrlOpened = false
_preLoginAuthUrl = authUrl
loginProcess.command = plan.command
loginProcess.running = true
if (needsLogin) loginTimeoutTimer.restart()
}
function switchAccount(id) {
var accountId = String(id || "")
if (!installed || accountId === "" || accountId === selectedAccountId || switchProcess.running) return
_switchOutput = ""
_switchError = ""
switchingAccountId = accountId
switchProcess.command = ["tailscale", "switch", accountId]
switchProcess.running = true
}
function exitNodeTarget(peer) {
if (!peer) return ""
if (peer.Mullvad === true) {
var mullvadIps = filterIPv4(peer.TailscaleIPs || [])
if (mullvadIps.length > 0) return mullvadIps[0]
}
return peerAddress(peer)
}
function setExitNode(peer) {
if (!installed || !running || !peer || exitNodeProcess.running) return
var active = peer.ExitNode === true
var target = active ? "" : exitNodeTarget(peer)
if (!active && target === "") return
_exitNodeOutput = ""
_exitNodeError = ""
settingExitNodeId = String(peer.id || "")
exitNodeProcess.command = ["tailscale", "set", "--exit-node=" + target]
exitNodeProcess.running = true
}
function authorizeTailscaleOperator() {
if (!installed || operatorProcess.running || userName === "") return
_operatorOutput = ""
_operatorError = ""
actionStatus = "Authorizing Tailscale operator..."
operatorProcess.command = ["pkexec", "tailscale", "set", "--operator=" + userName]
operatorProcess.running = true
}
function runAction(command, label) {
if (actionProcess.running) return
_actionOutput = ""
_actionError = ""
actionStatus = label || ""
actionProcess.command = command
actionProcess.running = true
}
function openAuthUrlFrom(text, allowFallback) {
if (_loginUrlOpened) return true
var match = String(text || "").match(/https?:\/\/\S+/)
var url = match && match[0] ? match[0] : (allowFallback === true ? authUrl : "")
if (url !== "") {
// Turning on ended up needing browser auth — stop pretending we're up.
_desired = -1
_loginUrlOpened = true
_loginInProgress = false
loginTimeoutTimer.stop()
Quickshell.execDetached(["omarchy-launch-browser", url])
return true
}
return false
}
function handleLoginOutput(data, isError) {
var text = String(data || "")
if (isError) _loginError += text + "\n"
else _loginOutput += text + "\n"
if (_loginInProgress && !_loginUrlOpened) openAuthUrlFrom(text, false)
}
Timer {
id: refreshTimer
interval: root.refreshIntervalSec * 1000
repeat: true
running: true
triggeredOnStart: true
onTriggered: root.refresh()
}
Timer {
// After a fresh boot the startup poll usually lands before tailscaled has
// connected, which left the icon stale until the next periodic refresh.
// Poll quickly until the service shows up, or give up after ~30 seconds.
id: startupRamp
property int ticks: 0
interval: 2000
repeat: true
running: true
onTriggered: {
ticks += 1
if (root.running || ticks >= 15) startupRamp.running = false
else root.refresh()
}
}
Timer {
id: delayedRefresh
interval: 600
repeat: false
onTriggered: root.refresh()
}
Timer {
// Every poll is skipped while its own process is still running, so one that
// never exits — tailscale can hang on a network that is coming and going —
// silently stops the panel refreshing at all, and it stays stopped. Reap
// anything still running well inside the refresh interval so the next tick
// starts clean.
id: pollWatchdog
interval: 15000
repeat: false
onTriggered: {
if (statusProcess.running) statusProcess.running = false
if (mullvadExitNodesProcess.running) mullvadExitNodesProcess.running = false
if (accountsProcess.running) accountsProcess.running = false
}
}
Timer {
id: actionStatusTimer
interval: 2200
repeat: false
onTriggered: root.actionStatus = ""
}
Timer {
id: loginTimeoutTimer
interval: 10000
repeat: false
onTriggered: {
if (!root._loginInProgress || root._loginUrlOpened) return
if (!root.openAuthUrlFrom(root.authUrl, true)) {
root._loginInProgress = false
root.actionStatus = "Tailscale login link not available yet"
}
}
}
Process {
id: whichProcess
running: false
command: []
onExited: function(exitCode) {
root.installed = exitCode === 0
if (root.installed) root.refreshStatusAndAccounts()
else {
root.refreshing = false
root.resetUnavailable("Not installed")
}
}
}
Process {
id: statusProcess
running: false
command: []
stdout: StdioCollector { id: statusStdout; waitForEnd: true; onStreamFinished: root._statusOutput = text }
stderr: StdioCollector { id: statusStderr; waitForEnd: true; onStreamFinished: root._statusError = text }
onExited: function(exitCode) {
root.refreshing = false
var stdout = String(statusStdout.text || root._statusOutput || "")
var stderr = String(statusStderr.text || root._statusError || "")
if (exitCode === 0) root.parseStatus(stdout)
else {
root.resetUnavailable("Disconnected")
root.lastError = stderr.trim()
}
}
}
Process {
id: accountsProcess
running: false
command: []
stdout: StdioCollector { id: accountsStdout; waitForEnd: true; onStreamFinished: root._accountsOutput = text }
stderr: StdioCollector { id: accountsStderr; waitForEnd: true; onStreamFinished: root._accountsError = text }
onExited: function(exitCode) {
var stdout = String(accountsStdout.text || root._accountsOutput || "")
var stderr = String(accountsStderr.text || root._accountsError || "")
if (exitCode === 0) root.parseAccounts(stdout)
else {
root.parseAccounts("")
if (/profiles access denied/i.test(stderr) || /profiles access denied/i.test(stdout)) {
root.accountsAccessDenied = true
root.lastError = "Authorize Tailscale operator to show connections"
} else {
root.lastError = elideStatus(stderr || stdout || "Could not list Tailscale connections")
}
}
}
}
Process {
id: mullvadExitNodesProcess
running: false
command: []
stdout: StdioCollector { id: mullvadExitNodesStdout; waitForEnd: true; onStreamFinished: root._mullvadExitNodesOutput = text }
stderr: StdioCollector { id: mullvadExitNodesStderr; waitForEnd: true; onStreamFinished: root._mullvadExitNodesError = text }
onExited: function(exitCode) {
var stdout = String(mullvadExitNodesStdout.text || root._mullvadExitNodesOutput || "")
if (exitCode === 0) root.parseMullvadExitNodes(stdout)
else root.parseMullvadExitNodes("")
}
}
Process {
id: actionProcess
running: false
command: []
stdout: StdioCollector { id: actionStdout; waitForEnd: true; onStreamFinished: root._actionOutput = text }
stderr: StdioCollector { id: actionStderr; waitForEnd: true; onStreamFinished: root._actionError = text }
onExited: function(exitCode) {
var stdout = String(actionStdout.text || root._actionOutput || "")
var stderr = String(actionStderr.text || root._actionError || "")
if (exitCode !== 0) {
root._desired = -1
root.lastError = elideStatus(stderr || stdout || "Tailscale command failed")
root.actionStatus = root.lastError
actionStatusTimer.restart()
} else {
root.lastError = ""
root.actionStatus = ""
}
delayedRefresh.restart()
}
}
Process {
id: loginProcess
running: false
command: []
stdout: SplitParser { onRead: function(data) { root.handleLoginOutput(data, false) } }
stderr: SplitParser { onRead: function(data) { root.handleLoginOutput(data, true) } }
onExited: function(exitCode) {
var combined = String(root._loginOutput || "") + "\n" + String(root._loginError || "")
var opened = root.openAuthUrlFrom(combined, true)
if (exitCode !== 0 && !opened) {
root._desired = -1
root._loginInProgress = false
root.lastError = elideStatus(combined || "tailscale up failed")
root.actionStatus = root.lastError
actionStatusTimer.restart()
} else if (!opened) {
root.lastError = ""
root.actionStatus = ""
}
delayedRefresh.restart()
}
}
Process {
id: switchProcess
running: false
command: []
stdout: StdioCollector { id: switchStdout; waitForEnd: true; onStreamFinished: root._switchOutput = text }
stderr: StdioCollector { id: switchStderr; waitForEnd: true; onStreamFinished: root._switchError = text }
onExited: function(exitCode) {
var stdout = String(switchStdout.text || root._switchOutput || "")
var stderr = String(switchStderr.text || root._switchError || "")
if (exitCode !== 0) {
root.lastError = elideStatus(stderr || stdout || "Account switch failed")
root.actionStatus = root.lastError
actionStatusTimer.restart()
} else {
root.lastError = ""
root.actionStatus = ""
root._lastAccountsRefreshMs = 0
}
root.switchingAccountId = ""
delayedRefresh.restart()
}
}
Process {
id: exitNodeProcess
running: false
command: []
stdout: StdioCollector { id: exitNodeStdout; waitForEnd: true; onStreamFinished: root._exitNodeOutput = text }
stderr: StdioCollector { id: exitNodeStderr; waitForEnd: true; onStreamFinished: root._exitNodeError = text }
onExited: function(exitCode) {
var stdout = String(exitNodeStdout.text || root._exitNodeOutput || "")
var stderr = String(exitNodeStderr.text || root._exitNodeError || "")
if (exitCode !== 0) {
root.lastError = elideStatus(stderr || stdout || "Exit node selection failed")
root.actionStatus = root.lastError
actionStatusTimer.restart()
} else {
root.lastError = ""
root.actionStatus = ""
}
root.settingExitNodeId = ""
delayedRefresh.restart()
}
}
Process {
id: operatorProcess
running: false
command: []
stdout: StdioCollector { id: operatorStdout; waitForEnd: true; onStreamFinished: root._operatorOutput = text }
stderr: StdioCollector { id: operatorStderr; waitForEnd: true; onStreamFinished: root._operatorError = text }
onExited: function(exitCode) {
var stdout = String(operatorStdout.text || root._operatorOutput || "")
var stderr = String(operatorStderr.text || root._operatorError || "")
if (exitCode !== 0) {
root.lastError = elideStatus(stderr || stdout || "Tailscale authorization failed")
root.actionStatus = root.lastError
actionStatusTimer.restart()
} else {
root.accountsAccessDenied = false
root.lastError = ""
root.actionStatus = "Tailscale operator authorized"
actionStatusTimer.restart()
root._lastAccountsRefreshMs = 0
}
delayedRefresh.restart()
}
}
}