diff --git a/Model.js b/Model.js index 4153067..bddf715 100644 --- a/Model.js +++ b/Model.js @@ -73,10 +73,35 @@ function loginPlan(needsLogin, authUrl) { return { authUrl: "", command: ["tailscale", "up"] } } +// Taildrop is a tailnet feature the admin can turn off, so the button for it +// only makes sense when this profile actually carries the capability. +function hasFileSharing(self) { + var capability = "https://tailscale.com/cap/file-sharing" + var capMap = (self && self.CapMap) || null + if (capMap && capMap[capability] !== undefined) return true + var capabilities = (self && self.Capabilities) || [] + for (var i = 0; i < capabilities.length; i++) { + if (String(capabilities[i]) === capability) return true + } + return false +} + +// Tailscale grades every peer itself — offline, wrong owner, an OS without +// Taildrop, no peer API — so take its word when the status carries one, and +// fall back to same-owner for daemons too old to say. +function isTaildropTarget(peer, selfUserId) { + var target = peer && peer.TaildropTarget + if (typeof target === "number" && target !== 0) return target === 1 + var owner = String((peer && peer.UserID) || "") + return owner !== "" && owner === String(selfUserId || "") +} + function peerFromStatus(id, peer) { return { id: id, HostName: displayHostName(peer.HostName, peer.DNSName), + UserID: String(peer.UserID || ""), + TaildropTarget: typeof peer.TaildropTarget === "number" ? peer.TaildropTarget : 0, DNSName: cleanDnsName(peer.DNSName), DisplayName: displayHostName(peer.HostName, peer.DNSName), TailscaleIPs: filterIPv4(peer.TailscaleIPs || []), @@ -235,6 +260,8 @@ function parseStatus(raw) { selfName: displayHostName(self.HostName, self.DNSName), selfDnsName: cleanDnsName(self.DNSName), selfIp: selfIps.length > 0 ? selfIps[0] : "", + selfUserId: String(self.UserID || ""), + fileSharing: hasFileSharing(self), peers: peers, exitNodes: exitNodes } @@ -285,6 +312,8 @@ if (typeof module !== "undefined") { osIcon: osIcon, accountLabel: accountLabel, loginPlan: loginPlan, + hasFileSharing: hasFileSharing, + isTaildropTarget: isTaildropTarget, isMullvadPeer: isMullvadPeer, peerFromStatus: peerFromStatus, parseExitNodeList: parseExitNodeList, diff --git a/Panel.qml b/Panel.qml index f188ca7..3202a68 100644 --- a/Panel.qml +++ b/Panel.qml @@ -295,6 +295,13 @@ Panel { scrollCursorIntoView() } + // The file picker takes over from here, so get the panel out of the way. + function sendPeerFile(peer) { + if (!tailscale.canSendFiles(peer)) return + tailscale.sendFile(peer) + close() + } + function openSelectedPeerCopyMenu() { if (!peerColumn || peerIndex < 0 || peerIndex >= peerColumn.children.length) return var item = peerColumn.children[peerIndex] @@ -416,6 +423,7 @@ Panel { else if (t === "c" || t === "C") tailscale.copyPeerIp(root.selectedPeer()) else if (t === "n" || t === "N") tailscale.copyPeerName(root.selectedPeer()) else if (t === "d" || t === "D") tailscale.copyPeerDnsName(root.selectedPeer()) + else if (t === "s" || t === "S") root.sendPeerFile(root.selectedPeer()) } Flickable { @@ -973,6 +981,17 @@ Panel { } } + PanelActionButton { + id: sendButton + visible: tailscale.canSendFiles(peerRow.peer) + iconText: "󰒊" + tooltipText: "Send files" + foreground: root.foreground + fontFamily: root.fontFamily + Layout.alignment: Qt.AlignVCenter + onClicked: root.sendPeerFile(peerRow.peer) + } + PanelActionButton { id: copyButton iconText: "󰆏" diff --git a/README.md b/README.md index cb54f50..5d542d4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Native Omarchy bar widget for Tailscale. - Switch between available Tailscale connections when multiple are available - Browse machines from `tailscale status --json` - Copy a machine's Tailscale IP, host name, or DNS name +- Send files to a machine with Taildrop, when the tailnet allows file sharing ## Keyboard shortcuts @@ -20,6 +21,7 @@ Inside the panel: - `c`: copy selected peer IP - `n`: copy selected peer name - `d`: copy selected peer DNS name +- `s`: send files to selected peer - `t`: toggle Tailscale - `r`: refresh status - `esc`: close @@ -28,6 +30,15 @@ Inside the panel: - `tailscale` CLI on `PATH` - `wl-copy` for clipboard copy actions +- Taildrop enabled for the tailnet, to send files + +## Receiving files + +Incoming Taildrop files are saved to `~/Downloads` by the +`omarchy-tailscale-receive` service, which announces each one with a +notification (an image preview when the file is an image, and a click to open +it). The Tailscale service install enables it; `omarchy tailscale receive` +runs the same loop by hand. ## Icon diff --git a/Service.qml b/Service.qml index ffb5eb1..13fbfbc 100644 --- a/Service.qml +++ b/Service.qml @@ -24,6 +24,8 @@ Item { 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: [] @@ -123,6 +125,26 @@ Item { 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) @@ -137,18 +159,21 @@ Item { 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 @@ -158,7 +183,13 @@ Item { _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) { @@ -175,6 +206,8 @@ Item { selfName = "" selfDnsName = "" selfIp = "" + selfUserId = "" + fileSharing = false authUrl = "" peers = [] exitNodes = [] @@ -212,6 +245,8 @@ Item { 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) : [] @@ -295,10 +330,7 @@ Item { var mullvadIps = filterIPv4(peer.TailscaleIPs || []) if (mullvadIps.length > 0) return mullvadIps[0] } - 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] : "" + return peerAddress(peer) } function setExitNode(peer) { @@ -386,6 +418,22 @@ Item { 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