diff --git a/Model.js b/Model.js index bddf715..c9d82b5 100644 --- a/Model.js +++ b/Model.js @@ -86,7 +86,7 @@ function hasFileSharing(self) { return false } -// Tailscale grades every peer itself — offline, wrong owner, an OS without +// Offscale 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) { diff --git a/OffscaleHero.qml b/OffscaleHero.qml new file mode 100644 index 0000000..803e73f --- /dev/null +++ b/OffscaleHero.qml @@ -0,0 +1,132 @@ +import QtQuick +import qs.Commons +import qs.Ui + +// Local copy of qs.Ui.PanelHero with one addition: `titleComponent`. +// +// The stock hero renders `title` as a plain Text and offers no slot to put +// anything else there, so showing the Offscale wordmark instead of a string +// needed either editing a shared /usr/share component (never) or owning a +// copy here. Everything else is unchanged from the original, so diffing +// against /usr/share/omarchy/shell/Ui/PanelHero.qml shows exactly what we did. +Item { + id: root + + property Component iconComponent: null + property string title: "" + // When set, replaces the title Text. `title` is still used as the accessible + // / fallback string, so callers should keep setting it. + property Component titleComponent: null + property string meta: "" + property string detail: "" + property color foreground: Color.foreground + property string fontFamily: Style.font.family + property real iconSize: Style.font.display + property real iconOpacity: 1.0 + property alias metaOpacity: metaText.opacity + + // Optional control pinned to the trailing edge of the hero — a ToggleSwitch, + // a small button. The hero centers it against the labels and reserves the + // space itself, so callers never do the geometry. + property Component trailingControl: null + + readonly property color dim: Qt.darker(foreground, 1.4) + readonly property real trailingInset: trailingLoader.item && trailingLoader.item.visible ? trailingLoader.width + Style.space(12) : 0 + + width: parent ? parent.width : implicitWidth + implicitHeight: Math.max(iconLoader.implicitHeight, heroLabels.implicitHeight, trailingLoader.implicitHeight) + + Loader { + id: iconLoader + sourceComponent: root.iconComponent + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + opacity: root.iconOpacity + } + + Column { + id: heroLabels + anchors.left: iconLoader.right + anchors.leftMargin: Style.space(14) + anchors.right: parent.right + anchors.rightMargin: root.trailingInset + anchors.verticalCenter: parent.verticalCenter + // Stock PanelHero uses space(2) = 3px, which is fine for two lines of text + // but too tight under an image wordmark. space(5) = 8px gives the machine + // name room to read as its own line. + spacing: Style.space(5) + + Row { + id: titleRow + visible: root.title !== "" || root.titleComponent !== null || detailPill.visible + width: parent.width + + // Either the custom title component or the stock Text — never both. + Loader { + id: titleSlot + visible: root.titleComponent !== null + active: root.titleComponent !== null + sourceComponent: root.titleComponent + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: titleText + visible: root.titleComponent === null && root.title !== "" + text: root.title + width: visible ? Math.min(implicitWidth, Math.max(0, parent.width - (detailPill.visible ? detailPill.implicitWidth + Style.space(8) : 0))) : 0 + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.title + font.bold: true + elide: Text.ElideRight + } + + Item { + width: Math.max(0, parent.width - titleSlot.width - titleText.width - detailPill.implicitWidth) + height: 1 + } + + BorderSurface { + id: detailPill + visible: root.detail !== "" + implicitWidth: detailText.implicitWidth + Style.space(10) + implicitHeight: detailText.implicitHeight + Style.space(4) + anchors.verticalCenter: parent.verticalCenter + color: "transparent" + borderSpec: Border.controlSpec("normal", root.foreground, Color.accent) + radius: Style.cornerRadius + + Text { + id: detailText + anchors.centerIn: parent + text: root.detail + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: true + } + } + } + + Text { + id: metaText + width: parent.width + text: root.meta.toUpperCase() + visible: text !== "" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + font.letterSpacing: 1.2 + elide: Text.ElideRight + } + } + + Loader { + id: trailingLoader + sourceComponent: root.trailingControl + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + } +} diff --git a/TailscaleIcon.qml b/OffscaleIcon.qml similarity index 50% rename from TailscaleIcon.qml rename to OffscaleIcon.qml index 8de7933..052aa33 100644 --- a/TailscaleIcon.qml +++ b/OffscaleIcon.qml @@ -16,23 +16,31 @@ Item { implicitWidth: iconSize implicitHeight: iconSize - readonly property real dotSize: Math.max(2, root.iconSize * 0.24) - readonly property real mid: (root.iconSize - dotSize) / 2 - readonly property real end: root.iconSize - dotSize - - // Native rendering of the Tailscale mark from the SVG: a 3×3 dot grid - // with the inactive dots faded. This avoids Qt SVG/effect rendering quirks - // in tiny bar slots while keeping the official silhouette. - Dot { x: 0; y: 0; opacity: 0.24 } - Dot { x: root.mid; y: 0; opacity: 0.24 } - Dot { x: root.end; y: 0; opacity: 0.24 } - Dot { x: 0; y: root.mid; opacity: 1.0 } - Dot { x: root.mid; y: root.mid; opacity: 1.0 } - Dot { x: root.end; y: root.mid; opacity: 1.0 } - Dot { x: 0; y: root.end; opacity: 0.24 } - Dot { x: root.mid; y: root.end; opacity: 1.0 } - Dot { x: root.end; y: root.end; opacity: 0.24 } + // The Offscale mark, replacing the stock 3x3 Tailscale dot grid. + // + // offscale-logo.png lives beside this file rather than being referenced from + // ~/projects, so the plugin stays self-contained and keeps working if that + // tree moves. It is the source PNG trimmed of its transparent margin, padded + // back to square so it centres, and scaled 1254 -> 256 (1.1 MB -> 77 KB). + // + // sourceSize caps the decode at 256 regardless of how large iconSize gets, so + // the bar slot never decodes more pixels than it needs; mipmap keeps the + // downscale to ~27px clean instead of aliased. + Image { + id: logo + anchors.fill: parent + source: Qt.resolvedUrl("offscale-logo.png") + sourceSize.width: 256 + sourceSize.height: 256 + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + // Dim the mark when Tailscale is down, matching how the stock icon faded + // its inactive dots. + opacity: root.crossed ? 0.45 : 1.0 + } + // Diagonal strike when disconnected. Rectangle { visible: root.crossed anchors.centerIn: parent @@ -43,6 +51,7 @@ Item { rotation: -45 } + // "!" badge when a login is needed. BorderSurface { visible: root.warning width: Math.max(7, parent.width * 0.42) @@ -62,11 +71,4 @@ Item { font.bold: true } } - - component Dot: Rectangle { - width: root.dotSize - height: root.dotSize - radius: width / 2 - color: root.color - } } diff --git a/Panel.qml b/Panel.qml index 34278ed..1441841 100644 --- a/Panel.qml +++ b/Panel.qml @@ -8,8 +8,8 @@ import qs.Ui Panel { id: root - moduleName: "omarchy.tailscale" - ipcTarget: "omarchy.tailscale" + moduleName: "pastilhas.offscale" + ipcTarget: "pastilhas.offscale" manageIpc: false property string focusSection: "header" @@ -22,20 +22,6 @@ Panel { property bool copyMenuOpen: false property bool mullvadPickerOpen: false property string mullvadQuery: "" - property int phraseIndex: 0 - readonly property var activePhrases: [ - "Encrypting connections", - "Sending secrets", - "Guarding wires", - "Braiding packets", - "Polishing tunnels", - "Hiding routes", - "Sealing ports", - "Sorting tailnets", - "Shuffling keys", - "Watching machines" - ] - readonly property string heroPhraseText: activePhrases[phraseIndex % activePhrases.length] readonly property color foreground: bar ? bar.foreground : Color.foreground readonly property color urgent: bar ? bar.urgent : Color.urgent @@ -52,7 +38,7 @@ Panel { // "header" stays navigable, but an absent CLI leaves nothing to highlight. readonly property bool headerHasCursor: cursorActive && focusSection === "header" && tailscale.installed readonly property color iconColor: tailscale.active ? foreground : dim - readonly property string toggleHint: tailscale.active ? "Turn Tailscale off" : (tailscale.needsLogin ? "Authorize this device" : "Turn Tailscale on") + readonly property string toggleHint: tailscale.active ? "Turn Offscale off" : (tailscale.needsLogin ? "Authorize this device" : "Turn Offscale on") readonly property color barIconColor: tailscale.active ? barForeground : Qt.darker(barForeground, 1.55) readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent" @@ -240,7 +226,7 @@ Panel { function activateCursor() { ensureCursor() if (focusSection === "header") { - tailscale.toggleTailscale() + tailscale.toggleOffscale() } else if (focusSection === "auth") { tailscale.authorizeTailscaleOperator() } else if (focusSection === "accounts") { @@ -373,7 +359,7 @@ Panel { function refresh(): string { tailscale.refresh(); return "ok" } function up(): string { tailscale.loginOrUp(); return "ok" } function down(): string { tailscale.down(); return "ok" } - function toggleTailscale(): string { tailscale.toggleTailscale(); return "ok" } + function toggleOffscale(): string { tailscale.toggleOffscale(); return "ok" } function status(): string { return tailscale.statusText } } @@ -381,11 +367,20 @@ Panel { id: button anchors.fill: parent bar: root.bar + // BarIconButton pins its icon Loader to an `opticalCanvas` sized by + // Style.bar.iconCanvas (16 * fontScale ~= 27px here). Growing iconSize + // alone can't get past that box, so widen the canvas first. slotSize is + // raised to match so the widget still reserves room for it in the bar. + opticalSize: Style.space(21) + slotSize: Style.space(23) iconComponent: Component { Item { - TailscaleIcon { + OffscaleIcon { anchors.centerIn: parent - iconSize: Style.space(11) + // Stock was space(11) = 18px at base-size 20 — noticeably smaller + // than the neighbouring widgets. space(19) ~= 32px, inside the + // widened canvas above. centerIn means it grows from the centre. + iconSize: Style.space(19) color: root.barIconColor badgeColor: root.urgent crossed: !tailscale.active && !tailscale.needsLogin @@ -394,7 +389,7 @@ Panel { } } onPressed: function(buttonCode) { - if (buttonCode === Qt.RightButton) tailscale.toggleTailscale() + if (buttonCode === Qt.RightButton) tailscale.toggleOffscale() else if (buttonCode === Qt.MiddleButton) tailscale.refresh() else root.toggle() } @@ -422,7 +417,7 @@ Panel { onCloseRequested: root.close() onTabRequested: function(direction) { root.switchPanel(direction) } onTextKey: function(t) { - if (t === "t" || t === "T") tailscale.toggleTailscale() + if (t === "t" || t === "T") tailscale.toggleOffscale() 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()) @@ -454,17 +449,44 @@ Panel { readonly property bool ringVisible: root.headerHasCursor function focusHero() { root.setHeaderCursor() } - PanelHero { + OffscaleHero { id: hero width: parent.width - title: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale" - meta: tailscale.active ? root.heroPhraseText : "Tailscale is disconnected" + // The wordmark replaces the title text; `title` is kept as the + // fallback string. The machine name moves down into `meta`, where + // the cycling phrases used to be. + title: tailscale.installed ? (tailscale.selfName || "Offscale") : "Offscale" + titleComponent: Component { + Image { + source: Qt.resolvedUrl("offscale-text.png") + // Asset is 773x128 (the source trimmed and scaled); cap the + // decode there and let the width follow the aspect ratio. + sourceSize.height: 128 + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + // Sized against the title font so it tracks base-size. + // 1.25 read a touch heavy next to the icon; 1.05 sits closer + // to the weight of the text it replaced. + height: Style.font.title * 1.05 + width: height * (773 / 128) + } + } + // No meta line: the header is the icon and the wordmark, centred + // against each other. metaText hides itself when empty, so the + // label Column collapses to the single title row and its + // verticalCenter lines up with the icon's. + // + // Losing this line also drops the "Offscale is disconnected" + // text, but that state is still carried by the icon (crossed and + // faded to 45%) and by the toggle switch on the trailing edge. + meta: "" foreground: root.foreground fontFamily: root.fontFamily iconOpacity: tailscale.active ? 1.0 : 0.5 // Status only — the switch owns toggling, mouse and keyboard alike. iconComponent: Component { - TailscaleIcon { + OffscaleIcon { iconSize: Style.font.display color: root.iconColor badgeColor: root.urgent @@ -485,7 +507,7 @@ Panel { hasCursor: header.ringVisible foreground: hero.foreground onHovered: function(on) { if (on) header.focusHero() } - onToggled: tailscale.toggleTailscale() + onToggled: tailscale.toggleOffscale() PanelToolTip { visible: powerSwitch.containsMouse @@ -497,6 +519,36 @@ Panel { } } + // The local machine, rendered with the same PeerRow used for the + // tailnet machines below — so it gets the identical layout, OS icon + // and copy menu (name / DNS / IP) for free. No section header: the + // row is the machine. + // + // The service exposes self as flat strings rather than a peer object, + // so the peer shape is assembled here. IPv6 is not among them, so + // that copy option simply doesn't appear. `navigable: false` keeps it + // out of the peers keyboard cursor, and canSendFiles() is false for + // self, so the Taildrop button hides itself. + PanelSeparator { + visible: tailscale.installed + foreground: root.foreground + } + + PeerRow { + visible: tailscale.installed + width: parent.width + navigable: false + rowIndex: -1 + peer: ({ + DisplayName: tailscale.selfName, + HostName: tailscale.selfName, + DNSName: tailscale.selfDnsName, + TailscaleIPs: tailscale.selfIp !== "" ? [tailscale.selfIp] : [], + TailscaleIPv6: [], + OS: "linux" + }) + } + Text { visible: tailscale.actionStatus !== "" || tailscale.lastError !== "" width: parent.width @@ -519,7 +571,7 @@ Panel { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.margins: Style.space(12) - text: "Tailscale CLI is not installed or not on PATH." + text: "Offscale CLI is not installed or not on PATH." color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.body @@ -713,28 +765,7 @@ Panel { } } - Timer { - id: phraseTimer - interval: 2800 - running: root.opened && tailscale.active - repeat: true - onTriggered: phraseSwap.restart() - } - SequentialAnimation { - id: phraseSwap - PropertyAnimation { - target: hero; property: "metaOpacity" - to: 0.0; duration: 180; easing.type: Easing.OutQuad - } - ScriptAction { - script: root.phraseIndex = (root.phraseIndex + 1) % root.activePhrases.length - } - PropertyAnimation { - target: hero; property: "metaOpacity" - to: 1.0; duration: 260; easing.type: Easing.InQuad - } - } component AuthRow: CursorSurface { id: authRow @@ -776,7 +807,7 @@ Panel { Text { Layout.fillWidth: true - text: "Authorize Tailscale operator" + text: "Authorize Offscale operator" color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.body @@ -785,7 +816,7 @@ Panel { Text { Layout.fillWidth: true - text: "Allow this user to operate this Tailscale profile" + text: "Allow this user to operate this Offscale profile" color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -882,7 +913,12 @@ Panel { } property int copyIndex: 0 - hasCursor: root.cursorActive && root.focusSection === "peers" && root.peerIndex === rowIndex + // Peers live in the keyboard-navigable "peers" section. The local machine + // row reuses this component purely for its copy menu, and must not claim + // the peers cursor or renumber it on hover — hence navigable: false there. + property bool navigable: true + + hasCursor: navigable && root.cursorActive && root.focusSection === "peers" && root.peerIndex === rowIndex foreground: root.foreground implicitHeight: Math.max(peerContent.implicitHeight, copyButton.implicitHeight) + Style.spacing.rowPaddingX @@ -921,7 +957,7 @@ Panel { acceptedButtons: Qt.LeftButton hoverEnabled: true cursorShape: Qt.ArrowCursor - onContainsMouseChanged: if (containsMouse) root.setPeerCursor(peerRow.rowIndex) + onContainsMouseChanged: if (containsMouse && peerRow.navigable) root.setPeerCursor(peerRow.rowIndex) } RowLayout { diff --git a/README.md b/README.md index a15e692..932f3b2 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ -# Tailscale Omarchy Widget +# Offscale Omarchy Widget -Native Omarchy bar widget for Tailscale. +Native Omarchy bar widget for Offscale. ## Features -- Shows Tailscale connection state in the bar +- Shows Offscale connection state in the bar - Left click opens a keyboard-friendly panel -- Right click toggles Tailscale on/off -- Switch between available Tailscale connections when multiple are available +- Right click toggles Offscale on/off +- Switch between available Offscale connections when multiple are available - Browse machines from `tailscale status --json` -- Copy a machine's Tailscale IP, host name, or DNS name +- Copy a machine's Offscale IP, host name, or DNS name - Send files to a machine with Taildrop, when the tailnet allows file sharing ## Keyboard shortcuts @@ -22,7 +22,7 @@ Inside the panel: - `n`: copy selected peer name - `d`: copy selected peer DNS name - `s`: send files to selected peer -- `t`: toggle Tailscale +- `t`: toggle Offscale - `r`: refresh status - `esc`: close @@ -37,12 +37,12 @@ Inside the panel: 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` +it). The Offscale service install enables it; `omarchy tailscale receive` runs the same loop by hand. ## Icon -Renders the Tailscale mark natively as a theme-colored 3×3 dot grid, matching the official SVG silhouette while avoiding tiny-SVG rendering quirks in the bar. +Renders the Offscale mark natively as a theme-colored 3×3 dot grid, matching the official SVG silhouette while avoiding tiny-SVG rendering quirks in the bar. ## Add to the bar diff --git a/Service.qml b/Service.qml index 13fbfbc..5849305 100644 --- a/Service.qml +++ b/Service.qml @@ -280,7 +280,7 @@ Item { exitNodes = running ? tailnetExitNodes.concat(mullvadRegions) : [] } - function toggleTailscale() { + function toggleOffscale() { if (!installed) return if (active) down() else loginOrUp() @@ -304,7 +304,7 @@ Item { } _loginOutput = "" _loginError = "" - if (needsLogin) actionStatus = "Starting Tailscale login…" + if (needsLogin) actionStatus = "Starting Offscale login…" else _desired = 1 _loginInProgress = needsLogin _loginUrlOpened = false @@ -349,7 +349,7 @@ Item { if (!installed || operatorProcess.running || userName === "") return _operatorOutput = "" _operatorError = "" - actionStatus = "Authorizing Tailscale operator..." + actionStatus = "Authorizing Offscale operator..." operatorProcess.command = ["pkexec", "tailscale", "set", "--operator=" + userName] operatorProcess.running = true } @@ -449,7 +449,7 @@ Item { if (!root._loginInProgress || root._loginUrlOpened) return if (!root.openAuthUrlFrom(root.authUrl, true)) { root._loginInProgress = false - root.actionStatus = "Tailscale login link not available yet" + root.actionStatus = "Offscale login link not available yet" } } } @@ -500,9 +500,9 @@ Item { 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" + root.lastError = "Authorize Offscale operator to show connections" } else { - root.lastError = elideStatus(stderr || stdout || "Could not list Tailscale connections") + root.lastError = elideStatus(stderr || stdout || "Could not list Offscale connections") } } } @@ -532,7 +532,7 @@ Item { var stderr = String(actionStderr.text || root._actionError || "") if (exitCode !== 0) { root._desired = -1 - root.lastError = elideStatus(stderr || stdout || "Tailscale command failed") + root.lastError = elideStatus(stderr || stdout || "Offscale command failed") root.actionStatus = root.lastError actionStatusTimer.restart() } else { @@ -621,13 +621,13 @@ Item { 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.lastError = elideStatus(stderr || stdout || "Offscale authorization failed") root.actionStatus = root.lastError actionStatusTimer.restart() } else { root.accountsAccessDenied = false root.lastError = "" - root.actionStatus = "Tailscale operator authorized" + root.actionStatus = "Offscale operator authorized" actionStatusTimer.restart() root._lastAccountsRefreshMs = 0 } diff --git a/manifest.json b/manifest.json index 8de2970..a7df191 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,11 @@ { "schemaVersion": 1, - "id": "omarchy.tailscale", - "name": "Tailscale", + "id": "pastilhas.offscale", + "name": "Offscale", "version": "1.0.0", - "author": "Omarchy", + "author": "pastilhas", "license": "MIT", - "description": "Tailscale status, connection switching, machine browsing, and quick copy actions in the Omarchy bar.", + "description": "Offscale status, connection switching, machine browsing, and quick copy actions in the Omarchy bar.", "kinds": [ "bar-widget" ], @@ -13,8 +13,8 @@ "barWidget": "Panel.qml" }, "barWidget": { - "displayName": "Tailscale", - "description": "Toggle Tailscale, switch connections, browse machines, and copy machine IPs or names.", + "displayName": "Offscale", + "description": "Toggle Offscale, switch connections, browse machines, and copy machine IPs or names.", "category": "Network", "allowMultiple": false, "defaultSection": "right", @@ -32,5 +32,8 @@ "defaultValue": 30 } ] + }, + "omarchy": { + "clonedFrom": "omarchy.tailscale" } } diff --git a/offscale-logo.png b/offscale-logo.png new file mode 100644 index 0000000..3a41f5c Binary files /dev/null and b/offscale-logo.png differ diff --git a/offscale-text.png b/offscale-text.png new file mode 100644 index 0000000..4db67d4 Binary files /dev/null and b/offscale-text.png differ