Rebrand to Offscale and rework the panel header

Forked from omarchy.tailscale at the packaged commit (omarchy-dev
4.0.0.r1744.gf002044). The `tailscale` branch holds the pristine upstream
subtree history so upstream changes can still be merged in; `master` is
this fork.

- Rename the plugin to pastilhas.offscale: manifest id/name, moduleName
  and ipcTarget, and display strings. Deliberately NOT renamed: the
  lowercase "tailscale" CLI invocations, the TailscaleIPs/TailscaleIPv6
  JSON fields from `tailscale status --json`, and
  authorizeTailscaleOperator. Renaming those loads fine but silently
  breaks peer parsing.
- TailscaleIcon.qml -> OffscaleIcon.qml, now rendering offscale-logo.png
  instead of the 3x3 dot grid, keeping the crossed/warning states.
- Add OffscaleHero.qml: a copy of qs.Ui.PanelHero with a titleComponent
  slot, so the wordmark (offscale-text.png) can replace the title text.
  Otherwise byte-identical to the original, so it stays diffable.
- Drop the cycling hero phrases entirely - the array, the 2.8s timer and
  the metaOpacity fade.
- Show the local machine as a PeerRow of its own, so it gets the same
  layout and copy menu as tailnet peers. Adds `navigable` to PeerRow so
  that row stays out of the peers keyboard cursor.
- Enlarge the bar icon: BarIconButton pins its icon to opticalCanvas, so
  opticalSize/slotSize are overridden alongside iconSize.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 17:04:41 +01:00
co-authored by Claude Opus 5
parent ba90129612
commit 0912b2b50d
9 changed files with 275 additions and 102 deletions
+1 -1
View File
@@ -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) {
+132
View File
@@ -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
}
}
+25 -23
View File
@@ -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
}
}
+90 -54
View File
@@ -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 {
+9 -9
View File
@@ -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
+9 -9
View File
@@ -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
}
+9 -6
View File
@@ -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"
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB