11 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 22d94428f6 Scroll the machine list independently of the panel
With a dozen-plus peers the whole panel scrolled, pushing the header, THIS
MACHINE, CONNECTIONS and EXIT NODES off the top to reach the machines. The
list now has its own Flickable, capped at Style.space(260) — about five
rows — so those sections stay in view while the machines move under them.

The cap only applies once it is needed: implicitHeight is
min(content, max), so a tailnet with three machines still renders as a
short panel. The outer scroller stays for when the panel as a whole
outgrows the screen.

scrollItemIntoView() took a single hardcoded scroller, which would have
left j/k in the peers section scrolling the panel rather than the list, and
the highlighted row hidden inside a list that never moved. It now takes the
target scroller, defaulting to the panel; peer navigation scrolls the list
into view first, then the row within it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:14:57 +01:00
pastilhasandClaude Opus 5 92e284c11a Update the wordmark to the corrected artwork
Re-embeds OffScale_Text.png after the brand-colour correction upstream in
OfficerIcons — the gold in the asset is now #FECF03, matching root.accent
exactly rather than approximately.

The new source has a different aspect ratio (830x128 against 773x128), so
the divisor in the Image width binding moves with it; left alone the
wordmark would render squashed. Noted inline so a future asset swap
doesn't miss it.

Uses the plain variant rather than OffScale_Text_Light.png: the plain one
pairs the gold with near-white (#FEFEFE), the Light variant with dark
brown (#312D23) for light backgrounds. The panel is dark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 02:57:32 +01:00
pastilhasandClaude Opus 5 54dec38b7e Clarify why the accent moved off the sampled value
The #FCC001 sample was accurate — the artwork was off-brand, and the icon
was recoloured to #FECF03 afterwards. The earlier note read as though
sampling had produced a wrong number, which is not what happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 02:34:43 +01:00
pastilhasandClaude Opus 5 954d4dbcf0 Use the canonical brand yellow #FECF03
Replaces #FCC001, which was measured off the hexagons in
OffScaleAppIcon.png. Sampling was the wrong approach: PNG antialiasing and
the icon's own shading make any eyedropper result an approximation of the
brand value rather than the value itself. #FECF03 (hue 48.8deg) is the
canonical one.

All three yellows resolve through root.accent — the bar's tailnet label,
the active connection name, and the IP/DNS subtitle — so this is a single
value. The wordmark asset is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 02:30:47 +01:00
pastilhasandClaude Opus 5 04ac049a6f New app icon, brand yellow, and bar-icon size compensation
- Replace the mark with OffScaleAppIcon.png, run through the same pipeline
  as before: trim the transparent margin, pad to square, scale to 256x256
  (654 KB -> 62 KB). One asset, two render sites.

- Retune the accent to #FCC001, the modal hexagon fill of the new icon.
  Sampled from fully-opaque pixels only — the yellows cluster at
  252,192-196,1-2, so this is the flat colour and not an antialiased edge.
  (`magick -alpha off` flattens onto black and gives a wrong answer here.)
  Replaces #FAE204 from the previous logo, which was noticeably greener.
  All three yellows — the bar's tailnet label, the active connection name,
  and the IP/DNS subtitle — resolve through root.accent, so this is one
  value.

- Hold the bar mark at 32px now that shell base-size is 24 rather than 20.
  Every size in the shell is a multiple of fontScale, so raising base-size
  to enlarge the stock bar icons would have scaled this one identically and
  left the relative gap unchanged; the icon/canvas/slot values are scaled
  down to compensate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 01:47:41 +01:00
pastilhasandClaude Opus 5 bbdf6cfa61 Make the tailnet label open the panel
The label was a plain Text with its own MouseArea, which is not registered
in bar.clickTargets. While a panel is open, KeyboardPanel puts a
full-screen overlay above everything and forwards clicks landing in the
bar strip only to registered targets — so a left click opened the panel
and then had the same click treated as "outside", dismissing it. It
looked like nothing happened. Right click was unaffected because
toggleOffscale() never involves the panel.

Use a WidgetButton instead: it registers itself via syncClickRegistration()
and renders the label text natively, so both the Text and the MouseArea
go away. Left toggles the panel, right toggles Offscale, middle refreshes
— the same three buttons as the icon, so the widget reads as one target.

`dimmed` handles the inactive state rather than darkening the colour,
since Qt.darker on yellow shifts the hue to olive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 22:43:41 +01:00
pastilhasandClaude Opus 5 91cf1130e0 Show the current tailnet next to the bar icon
Adds a label to the left of the mark naming the tailnet the selected
profile belongs to: the second-from-last label of the domain, so
offscale.pastilhas.dev reads "pastilhas" and headscale.pertento.ai reads
"pertento". Taking the second-level domain rather than a fixed index keeps
the distinctive part if a tailnet ever has four labels.

Read from the accounts list the service already polls, so no extra
process runs for it.

BarIconButton is icon-only by design (labelVisible: false, fixedWidth:
slotSize, and its `text` feeds a glyph rather than a label), so the label
could not live inside it — the bar widget is now a Row of label + button,
and its footprint comes from the row.

Coloured with the brand yellow, dimmed by opacity rather than by
darkening when Tailscale is off: Qt.darker on yellow shifts the hue to
olive instead of quietening it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 22:25:11 +01:00
pastilhasandClaude Opus 5 ca341c0e71 Rework the CONNECTIONS section
- Label each connection with its tailnet and account rather than the
  nickname alone. `tailscale switch --list --json` carries both, and on a
  multi-tailnet setup the nickname is the least useful field — "andre"
  says nothing about which tailnet you would be switching to. Renders as
  "headscale.pertento.ai (andre)", falling back through tailnet, then
  account, then profile id when a field is missing.

- Mark the active connection with colour instead of a filled background:
  brand yellow on its label, currentFill cleared. The theme sets
  selected-border-width to 0, so clearing the fill leaves nothing drawn.
  Hover still highlights through `fill`, so the row keeps its affordance.
  Exit-node and Mullvad-region rows are untouched and still fill.

- Sort the active connection to the top. Done in parseAccounts rather
  than at render time: the keyboard cursor indexes into
  tailscale.accounts, so sorting only in the view would leave j/k acting
  on a different row than the highlighted one. Array.sort is stable, so
  the remaining profiles keep the CLI's order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 22:17:41 +01:00
pastilhasandClaude Opus 5 ad997bd6b1 Brand the panel: yellow IP/DNS line, white toggle
- Colour the IP / DNS subtitle under each machine with the logo yellow
  (#FAE204, sampled as the dominant opaque colour of offscale-logo.png).
  Stock draws that line through `dim` = Qt.darker(foreground, 1.55), which
  on yellow shifts the hue to olive (#A19203) rather than quietening it, so
  it takes the flat accent instead. Everything else keeps the themed
  foreground: section headers, machine names, account names and all the
  iconography.

- Add OffscaleToggle.qml so the switch reads white when on. The stock
  ToggleSwitch resolves its checked colours through
  Style.selectedStateColor / selectedFillFor, which read [controls]
  selected-color; Ethereal sets that to a literal "#ffcead" and
  Style.resolveStateColor returns literals verbatim, ignoring the
  foreground and accent properties entirely. No property on the stock
  switch can reach it, and overriding the token globally would recolour
  every selected state in the shell. The copy differs only in the checked
  state and needs an explicit `import qs.Ui`, which the original gets
  implicitly by living inside that module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:31:30 +01:00
pastilhasandClaude Opus 5 b9bb75e8f5 Update the mark to OffscaleLogo3
Regenerated from OfficerIcons/NewBrief/OffscaleLogo3.png with the same
pipeline as before: trim the transparent margin, pad back to square so it
centres, scale to 256x256 (1.08 MB -> 78 KB).

One asset, two render sites — OffscaleIcon.qml loads it and is
instantiated both in the bar and in the panel hero, so this covers both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:13:01 +01:00
pastilhasandClaude Opus 5 0912b2b50d 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>
2026-08-15 17:04:41 +01:00
10 changed files with 579 additions and 132 deletions
+31 -4
View File
@@ -57,11 +57,25 @@ function osIcon(os) {
return "󰟀" return "󰟀"
} }
// Stock returned the nickname alone, which on a multi-tailnet setup is the
// least useful field — "andre" and "pastilhas" say nothing about which tailnet
// you would be switching to. `tailscale switch --list --json` carries both, so
// show the tailnet with the account in parentheses:
//
// headscale.pertento.ai (andre)
// offscale.pastilhas.dev (pastilhas)
//
// Falls back through the same chain as before when a field is absent, so a
// profile with no tailnet still renders something meaningful.
function accountLabel(account) { function accountLabel(account) {
if (!account) return "Unknown account" if (!account) return "Unknown account"
if (account.nickname) return String(account.nickname)
if (account.tailnet) return String(account.tailnet) var tailnet = String(account.tailnet || "").trim()
if (account.account) return String(account.account) var who = String(account.account || account.nickname || "").trim()
if (tailnet !== "" && who !== "") return tailnet + " (" + who + ")"
if (tailnet !== "") return tailnet
if (who !== "") return who
return String(account.id || "Unknown account") return String(account.id || "Unknown account")
} }
@@ -86,7 +100,7 @@ function hasFileSharing(self) {
return false 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 // 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. // fall back to same-owner for daemons too old to say.
function isTaildropTarget(peer, selfUserId) { function isTaildropTarget(peer, selfUserId) {
@@ -292,6 +306,19 @@ function parseAccounts(raw) {
if (account.selected === true) selected = account if (account.selected === true) selected = account
} }
} }
// Active connection first, everything else in the order the CLI listed it.
// Sorted here rather than in the view so the Repeater and the keyboard
// cursor (accountIndex) index into the same ordering — sorting only at
// render time would leave j/k selecting a different row than the one
// highlighted. Array.sort is stable, so the unselected profiles keep their
// relative order.
next.sort(function(left, right) {
var l = left.selected === true ? 0 : 1
var r = right.selected === true ? 0 : 1
return l - r
})
return { return {
accounts: next, accounts: next,
selectedAccountId: selected ? String(selected.id || "") : "", selectedAccountId: selected ? String(selected.id || "") : "",
+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 implicitWidth: iconSize
implicitHeight: iconSize implicitHeight: iconSize
readonly property real dotSize: Math.max(2, root.iconSize * 0.24) // The Offscale mark, replacing the stock 3x3 Tailscale dot grid.
readonly property real mid: (root.iconSize - dotSize) / 2 //
readonly property real end: root.iconSize - dotSize // offscale-logo.png lives beside this file rather than being referenced from
// ~/projects, so the plugin stays self-contained and keeps working if that
// Native rendering of the Tailscale mark from the SVG: a 3×3 dot grid // tree moves. It is the source PNG trimmed of its transparent margin, padded
// with the inactive dots faded. This avoids Qt SVG/effect rendering quirks // back to square so it centres, and scaled 1254 -> 256 (1.1 MB -> 77 KB).
// in tiny bar slots while keeping the official silhouette. //
Dot { x: 0; y: 0; opacity: 0.24 } // sourceSize caps the decode at 256 regardless of how large iconSize gets, so
Dot { x: root.mid; y: 0; opacity: 0.24 } // the bar slot never decodes more pixels than it needs; mipmap keeps the
Dot { x: root.end; y: 0; opacity: 0.24 } // downscale to ~27px clean instead of aliased.
Dot { x: 0; y: root.mid; opacity: 1.0 } Image {
Dot { x: root.mid; y: root.mid; opacity: 1.0 } id: logo
Dot { x: root.end; y: root.mid; opacity: 1.0 } anchors.fill: parent
Dot { x: 0; y: root.end; opacity: 0.24 } source: Qt.resolvedUrl("offscale-logo.png")
Dot { x: root.mid; y: root.end; opacity: 1.0 } sourceSize.width: 256
Dot { x: root.end; y: root.end; opacity: 0.24 } 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 { Rectangle {
visible: root.crossed visible: root.crossed
anchors.centerIn: parent anchors.centerIn: parent
@@ -43,6 +51,7 @@ Item {
rotation: -45 rotation: -45
} }
// "!" badge when a login is needed.
BorderSurface { BorderSurface {
visible: root.warning visible: root.warning
width: Math.max(7, parent.width * 0.42) width: Math.max(7, parent.width * 0.42)
@@ -62,11 +71,4 @@ Item {
font.bold: true font.bold: true
} }
} }
component Dot: Rectangle {
width: root.dotSize
height: root.dotSize
radius: width / 2
color: root.color
}
} }
+125
View File
@@ -0,0 +1,125 @@
// Local copy of qs.Ui.ToggleSwitch, changed in exactly one respect: the
// "on" colours come from `foreground` instead of the theme's selected-state
// tokens.
//
// Stock resolves them through Style.selectedStateColor / selectedFillFor,
// which read [controls] selected-color from the active theme. Ethereal sets
// that to a literal "#ffcead", and Style.resolveStateColor returns a literal
// verbatim — the `foreground` and `accent` properties are ignored entirely.
// So no property on the stock switch can make the checked knob white; only
// overriding [controls] selected-color globally would, and that would recolour
// every selected state across the shell (buttons, sliders, and the audio,
// bluetooth, network and clock panels).
//
import QtQuick
import qs.Commons
import qs.Ui // BorderSurface: implicit for the original inside qs.Ui, explicit here
// Bare on/off switch: a track with a sliding knob and no label. This is the
// switch `Toggle` parks at the end of its labeled row, factored out so panel
// headers and other compact controls render the identical thing.
//
// The caller owns the value: bind `checked` to real state and flip it in
// response to `toggled()`. Services that already track a desired state
// optimistically (see the Tailscale service's `_desired`) get an instant knob
// throw for free, because `checked` is already the optimistic value.
//
// `busy` marks an operation in flight and swallows further clicks, but leaves
// hover, cursor, and tooltips alone so the control does not flicker every time
// a background refresh runs.
//
// The cursor is a ring drawn outside the track rather than a state on the
// track itself: themes give normal chrome a stronger border than hover-cursor
// (0.4 vs 0.25 by default), which is right for controls that are borderless at
// rest but would make a bordered track go *fainter* under the cursor. On the
// panel background the ring reads immediately. `cursorRing` follows
// `interactive` — a switch whose surrounding row owns the click owns the
// cursor too.
//
// `rounded` auto-detects from Style.cornerRadius so the switch follows the
// theme: pill shape when Hyprland corners are rounded, square on sharp.
Item {
id: root
property bool checked: false
property bool busy: false
// Off when the surrounding row owns the click, as in `Toggle`.
property bool interactive: true
// Panel-cursor flag. Same role as Button.hasCursor: panels with their own
// keyboard cursor bind this to drive the highlight separately from hover.
property bool hasCursor: false
property bool cursorRing: interactive
property int cursorPad: Style.space(6)
property bool rounded: Style.cornerRadius > 0
property color foreground: Color.foreground
property color accent: Color.accent
signal toggled()
signal hovered(bool isHovered)
readonly property alias containsMouse: mouse.containsMouse
readonly property bool hot: hasCursor || mouse.containsMouse
// `trackHeight` is settable so a compact placement — a switch riding a panel
// section header, say — can ask for a genuinely smaller control instead of
// scaling a big one down, which lands the track and knob on fractional pixels
// and blurs their edges. The derived sizes only carry floors low enough to
// stay out of an override's way; at the default track height each one is
// already above its floor, so nothing about the normal switch changes.
property int trackHeight: Math.max(22, Math.round(Style.spacing.controlHeight * 0.55))
property int trackWidth: Math.round(trackHeight * 1.9)
property int knobSize: Math.max(6, Math.round(trackHeight * 0.72))
property int knobInset: Math.max(1, Math.round((trackHeight - knobSize) / 2))
readonly property int _pad: cursorRing ? cursorPad : 0
implicitWidth: trackWidth + _pad * 2
implicitHeight: trackHeight + _pad * 2
BorderSurface {
anchors.fill: parent
visible: root.cursorRing && root.hot
color: "transparent"
radius: Style.cornerRadius
borderSpec: Border.controlSpec("hover-cursor", root.foreground, root.accent)
}
BorderSurface {
id: track
width: root.trackWidth
height: root.trackHeight
anchors.centerIn: parent
radius: root.rounded ? height / 2 : 0
color: root.checked
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18)
: Style.normalFillFor(root.foreground, root.accent)
borderSpec: Border.controlSpec("normal", root.foreground, root.accent)
Behavior on color { ColorAnimation { duration: 120 } }
Rectangle {
width: root.knobSize
height: root.knobSize
radius: root.rounded ? height / 2 : 0
x: root.checked ? track.width - width - root.knobInset : root.knobInset
anchors.verticalCenter: parent.verticalCenter
color: root.checked ? root.foreground : Qt.darker(root.foreground, 1.25)
Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 120 } }
}
}
MouseArea {
id: mouse
anchors.fill: parent
enabled: root.interactive
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: root.hovered(containsMouse)
onClicked: if (!root.busy) root.toggled()
}
}
+230 -72
View File
@@ -8,8 +8,8 @@ import qs.Ui
Panel { Panel {
id: root id: root
moduleName: "omarchy.tailscale" moduleName: "pastilhas.offscale"
ipcTarget: "omarchy.tailscale" ipcTarget: "pastilhas.offscale"
manageIpc: false manageIpc: false
property string focusSection: "header" property string focusSection: "header"
@@ -22,24 +22,26 @@ Panel {
property bool copyMenuOpen: false property bool copyMenuOpen: false
property bool mullvadPickerOpen: false property bool mullvadPickerOpen: false
property string mullvadQuery: "" 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]
// Text stays on the bar's themed colour: section headers, machine names and
// account names read white.
readonly property color foreground: bar ? bar.foreground : Color.foreground readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property color urgent: bar ? bar.urgent : Color.urgent readonly property color urgent: bar ? bar.urgent : Color.urgent
readonly property color dim: Qt.darker(foreground, 1.55) readonly property color dim: Qt.darker(foreground, 1.55)
// Brand yellow/gold: #FECF03, hue 48.8deg — the canonical brand value.
//
// Briefly #FCC001, sampled from the hexagons in OffScaleAppIcon.png. That
// reading was correct; the artwork was the thing that was off-brand, and the
// icon was recoloured to match this value afterwards. Keep this in sync with
// the brand rather than with whatever the current asset happens to contain.
//
// Everything yellow in the plugin resolves through here: the tailnet label in
// the bar, the active connection's name, and the IP / DNS subtitle under each
// machine. That subtitle takes the accent flat rather than through `dim`,
// because darkening yellow shifts its hue to olive rather than quietening it.
// Icons stay on the themed foreground.
readonly property color accent: "#FECF03"
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
readonly property bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied readonly property bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied
readonly property bool showPeers: tailscale.active && tailscale.peers.length > 0 readonly property bool showPeers: tailscale.active && tailscale.peers.length > 0
@@ -52,7 +54,7 @@ Panel {
// "header" stays navigable, but an absent CLI leaves nothing to highlight. // "header" stays navigable, but an absent CLI leaves nothing to highlight.
readonly property bool headerHasCursor: cursorActive && focusSection === "header" && tailscale.installed readonly property bool headerHasCursor: cursorActive && focusSection === "header" && tailscale.installed
readonly property color iconColor: tailscale.active ? foreground : dim 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 barIconColor: tailscale.active ? barForeground : Qt.darker(barForeground, 1.55)
readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent"
readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent" readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent"
@@ -240,7 +242,7 @@ Panel {
function activateCursor() { function activateCursor() {
ensureCursor() ensureCursor()
if (focusSection === "header") { if (focusSection === "header") {
tailscale.toggleTailscale() tailscale.toggleOffscale()
} else if (focusSection === "auth") { } else if (focusSection === "auth") {
tailscale.authorizeTailscaleOperator() tailscale.authorizeTailscaleOperator()
} else if (focusSection === "accounts") { } else if (focusSection === "accounts") {
@@ -265,24 +267,34 @@ Panel {
if (region) chooseExitNode(region) if (region) chooseExitNode(region)
} }
function scrollItemIntoView(item) { // `flick` defaults to the whole-panel scroller. The machine list has its own
if (!panelFlick || !item) return // Flickable, so rows in that section pass it explicitly — scrolling the outer
// one to reveal a peer would move the panel rather than the list, and the row
// would stay hidden inside a list that never scrolled.
function scrollItemIntoView(item, flick) {
var view = flick || panelFlick
if (!view || !item) return
Qt.callLater(function() { Qt.callLater(function() {
if (!item) return if (!item) return
var margin = Style.space(6) var margin = Style.space(6)
var point = item.mapToItem(panelFlick.contentItem, 0, 0) var point = item.mapToItem(view.contentItem, 0, 0)
var top = point.y var top = point.y
var bottom = top + item.height var bottom = top + item.height
var viewTop = panelFlick.contentY var viewTop = view.contentY
var viewBottom = viewTop + panelFlick.height var viewBottom = viewTop + view.height
var maxY = Math.max(0, panelFlick.contentHeight - panelFlick.height) var maxY = Math.max(0, view.contentHeight - view.height)
if (top < viewTop + margin) panelFlick.contentY = Math.max(0, top - margin) if (top < viewTop + margin) view.contentY = Math.max(0, top - margin)
else if (bottom > viewBottom - margin) panelFlick.contentY = Math.min(maxY, bottom + margin - panelFlick.height) else if (bottom > viewBottom - margin) view.contentY = Math.min(maxY, bottom + margin - view.height)
}) })
} }
function scrollCursorIntoView() { function scrollCursorIntoView() {
if (focusSection === "peers" && peerColumn && peerIndex >= 0 && peerIndex < peerColumn.children.length) scrollItemIntoView(peerColumn.children[peerIndex]) if (focusSection === "peers" && peerColumn && peerIndex >= 0 && peerIndex < peerColumn.children.length) {
// Bring the machine list itself into view first, then the row within it —
// otherwise the row scrolls inside a list that is still off-screen.
scrollItemIntoView(peerFlick)
scrollItemIntoView(peerColumn.children[peerIndex], peerFlick)
}
else if (focusSection === "exitNodes" && exitNodeColumn && exitNodeIndex >= 0 && exitNodeIndex < exitNodeColumn.children.length) scrollItemIntoView(exitNodeColumn.children[exitNodeIndex]) else if (focusSection === "exitNodes" && exitNodeColumn && exitNodeIndex >= 0 && exitNodeIndex < exitNodeColumn.children.length) scrollItemIntoView(exitNodeColumn.children[exitNodeIndex])
} }
@@ -334,8 +346,31 @@ Panel {
headerIndex = 0 headerIndex = 0
} }
implicitWidth: button.implicitWidth implicitWidth: barContent.implicitWidth
implicitHeight: button.implicitHeight implicitHeight: barContent.implicitHeight
// Short name of the tailnet the selected profile belongs to, shown in the bar
// beside the icon. Takes the second-from-last label of the domain, which is
// the distinctive part:
//
// headscale.pertento.ai -> pertento
// offscale.pastilhas.dev -> pastilhas
//
// Reads from the accounts list the service already polls, so no extra process
// runs for this.
readonly property string tailnetName: {
var list = tailscale.accounts
for (var i = 0; i < list.length; i++) {
if (list[i].selected !== true) continue
var parts = String(list[i].tailnet || "").split(".")
var clean = []
for (var j = 0; j < parts.length; j++) if (parts[j] !== "") clean.push(parts[j])
if (clean.length >= 2) return clean[clean.length - 2]
if (clean.length === 1) return clean[0]
return ""
}
return ""
}
onOpenedChanged: if (opened) { onOpenedChanged: if (opened) {
cursorActive = false cursorActive = false
@@ -373,19 +408,63 @@ Panel {
function refresh(): string { tailscale.refresh(); return "ok" } function refresh(): string { tailscale.refresh(); return "ok" }
function up(): string { tailscale.loginOrUp(); return "ok" } function up(): string { tailscale.loginOrUp(); return "ok" }
function down(): string { tailscale.down(); 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 } function status(): string { return tailscale.statusText }
} }
// Tailnet label to the left of the mark. BarIconButton is icon-only by design
// (labelVisible: false, fixedWidth: slotSize, and its `text` feeds a glyph
// rather than a label), so the name sits beside it in a Row instead.
Row {
id: barContent
anchors.fill: parent
spacing: Style.space(4)
// A WidgetButton rather than a plain Text with a MouseArea, because
// WidgetButton registers itself in bar.clickTargets. That registration is
// what makes left-click work: while a panel is open, KeyboardPanel puts a
// full-screen overlay above everything and forwards clicks in the bar strip
// only to registered targets. An unregistered MouseArea would open the
// panel on press and then have the same click treated as "outside" and
// dismiss it — the panel appeared not to open at all.
WidgetButton {
id: tailnetLabel
visible: text !== ""
bar: root.bar
text: root.tailnetName
// Brand yellow. `dimmed` fades the whole button when Tailscale is off,
// rather than darkening the colour — Qt.darker on yellow slides the hue
// to olive instead of quietening it, the same trap as the IP/DNS line.
foreground: root.accent
useActiveColor: false
dimmed: !tailscale.active
fontSize: Style.bar.iconFont
anchors.verticalCenter: parent.verticalCenter
onPressed: function(buttonCode) {
if (buttonCode === Qt.RightButton) tailscale.toggleOffscale()
else if (buttonCode === Qt.MiddleButton) tailscale.refresh()
else root.toggle()
}
}
BarIconButton { BarIconButton {
id: button id: button
anchors.fill: parent anchors.verticalCenter: parent.verticalCenter
bar: root.bar 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(18)
slotSize: Style.space(19)
iconComponent: Component { iconComponent: Component {
Item { Item {
TailscaleIcon { OffscaleIcon {
anchors.centerIn: parent 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(16)
color: root.barIconColor color: root.barIconColor
badgeColor: root.urgent badgeColor: root.urgent
crossed: !tailscale.active && !tailscale.needsLogin crossed: !tailscale.active && !tailscale.needsLogin
@@ -394,11 +473,12 @@ Panel {
} }
} }
onPressed: function(buttonCode) { onPressed: function(buttonCode) {
if (buttonCode === Qt.RightButton) tailscale.toggleTailscale() if (buttonCode === Qt.RightButton) tailscale.toggleOffscale()
else if (buttonCode === Qt.MiddleButton) tailscale.refresh() else if (buttonCode === Qt.MiddleButton) tailscale.refresh()
else root.toggle() else root.toggle()
} }
} }
}
KeyboardPanel { KeyboardPanel {
id: panel id: panel
@@ -422,7 +502,7 @@ Panel {
onCloseRequested: root.close() onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) } onTabRequested: function(direction) { root.switchPanel(direction) }
onTextKey: function(t) { 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 === "c" || t === "C") tailscale.copyPeerIp(root.selectedPeer())
else if (t === "n" || t === "N") tailscale.copyPeerName(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 === "d" || t === "D") tailscale.copyPeerDnsName(root.selectedPeer())
@@ -454,17 +534,46 @@ Panel {
readonly property bool ringVisible: root.headerHasCursor readonly property bool ringVisible: root.headerHasCursor
function focusHero() { root.setHeaderCursor() } function focusHero() { root.setHeaderCursor() }
PanelHero { OffscaleHero {
id: hero id: hero
width: parent.width width: parent.width
title: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale" // The wordmark replaces the title text; `title` is kept as the
meta: tailscale.active ? root.heroPhraseText : "Tailscale is disconnected" // 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 830x128 (the source trimmed and scaled); cap the
// decode there and let the width follow the aspect ratio.
// Keep the divisor below in step if the wordmark is replaced —
// a different aspect ratio renders squashed otherwise.
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 * (830 / 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 foreground: root.foreground
fontFamily: root.fontFamily fontFamily: root.fontFamily
iconOpacity: tailscale.active ? 1.0 : 0.5 iconOpacity: tailscale.active ? 1.0 : 0.5
// Status only — the switch owns toggling, mouse and keyboard alike. // Status only — the switch owns toggling, mouse and keyboard alike.
iconComponent: Component { iconComponent: Component {
TailscaleIcon { OffscaleIcon {
iconSize: Style.font.display iconSize: Style.font.display
color: root.iconColor color: root.iconColor
badgeColor: root.urgent badgeColor: root.urgent
@@ -477,15 +586,21 @@ Panel {
// header's only cursor target. The service already flips `active` // header's only cursor target. The service already flips `active`
// optimistically, so the knob throws the instant you click it. // optimistically, so the knob throws the instant you click it.
trailingControl: Component { trailingControl: Component {
ToggleSwitch { OffscaleToggle {
id: powerSwitch id: powerSwitch
visible: tailscale.installed visible: tailscale.installed
checked: tailscale.active checked: tailscale.active
busy: tailscale.busy busy: tailscale.busy
hasCursor: header.ringVisible hasCursor: header.ringVisible
foreground: hero.foreground foreground: hero.foreground
// ToggleSwitch defaults `accent` to Color.accent — the theme
// accent, which is where the off-white/peach knob and border
// came from. The hero only forwards `foreground`, so pin
// accent to it as well and the switch reads white in both the
// connected and disconnected states.
accent: hero.foreground
onHovered: function(on) { if (on) header.focusHero() } onHovered: function(on) { if (on) header.focusHero() }
onToggled: tailscale.toggleTailscale() onToggled: tailscale.toggleOffscale()
PanelToolTip { PanelToolTip {
visible: powerSwitch.containsMouse visible: powerSwitch.containsMouse
@@ -497,6 +612,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 { Text {
visible: tailscale.actionStatus !== "" || tailscale.lastError !== "" visible: tailscale.actionStatus !== "" || tailscale.lastError !== ""
width: parent.width width: parent.width
@@ -519,7 +664,7 @@ Panel {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.margins: Style.space(12) 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 color: root.dim
font.family: root.fontFamily font.family: root.fontFamily
font.pixelSize: Style.font.body font.pixelSize: Style.font.body
@@ -690,10 +835,31 @@ Panel {
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
} }
Column { // The machine list scrolls on its own rather than pushing the
id: peerColumn // sections above it off the top of the panel. Its height is the
// content height until that passes maxPeerListHeight, after which
// it stops growing and scrolls internally — so a tailnet with three
// machines still renders as a short panel, and one with thirty
// keeps the header, THIS MACHINE, CONNECTIONS and EXIT NODES in
// view while the machines move under them.
Flickable {
id: peerFlick
visible: root.showPeers visible: root.showPeers
width: parent.width width: parent.width
readonly property real maxPeerListHeight: Style.space(260)
implicitHeight: Math.min(peerColumn.implicitHeight, maxPeerListHeight)
height: implicitHeight
contentWidth: width
contentHeight: peerColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
interactive: contentHeight > height
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Column {
id: peerColumn
width: peerFlick.width
spacing: Style.space(6) spacing: Style.space(6)
Repeater { Repeater {
@@ -712,29 +878,9 @@ 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 { component AuthRow: CursorSurface {
id: authRow id: authRow
@@ -776,7 +922,7 @@ Panel {
Text { Text {
Layout.fillWidth: true Layout.fillWidth: true
text: "Authorize Tailscale operator" text: "Authorize Offscale operator"
color: root.foreground color: root.foreground
font.family: root.fontFamily font.family: root.fontFamily
font.pixelSize: Style.font.body font.pixelSize: Style.font.body
@@ -785,7 +931,7 @@ Panel {
Text { Text {
Layout.fillWidth: true Layout.fillWidth: true
text: "Allow this user to operate this Tailscale profile" text: "Allow this user to operate this Offscale profile"
color: root.dim color: root.dim
font.family: root.fontFamily font.family: root.fontFamily
font.pixelSize: Style.font.caption font.pixelSize: Style.font.caption
@@ -808,7 +954,12 @@ Panel {
current: selectedAccount current: selectedAccount
foreground: root.foreground foreground: root.foreground
fill: root.hoverFill fill: root.hoverFill
currentFill: root.selectedFill // No filled background on the active connection — it reads as the brand
// yellow on its label instead (see the Text below). `current` also selects
// the "selected" border spec, but the theme sets selected-border-width to
// 0, so clearing the fill leaves nothing drawn. Hover still highlights
// through `fill`, so the row keeps its affordance.
currentFill: "transparent"
implicitHeight: accountInner.implicitHeight + Style.spacing.xl implicitHeight: accountInner.implicitHeight + Style.spacing.xl
@@ -842,7 +993,9 @@ Panel {
Text { Text {
text: accountRow.accountText text: accountRow.accountText
color: root.foreground // The active connection is marked by colour rather than a filled
// background: brand yellow here, themed white for the others.
color: accountRow.selectedAccount ? root.accent : root.foreground
font.family: root.fontFamily font.family: root.fontFamily
font.pixelSize: Style.font.body font.pixelSize: Style.font.body
font.bold: accountRow.selectedAccount font.bold: accountRow.selectedAccount
@@ -882,7 +1035,12 @@ Panel {
} }
property int copyIndex: 0 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 foreground: root.foreground
implicitHeight: Math.max(peerContent.implicitHeight, copyButton.implicitHeight) + Style.spacing.rowPaddingX implicitHeight: Math.max(peerContent.implicitHeight, copyButton.implicitHeight) + Style.spacing.rowPaddingX
@@ -921,7 +1079,7 @@ Panel {
acceptedButtons: Qt.LeftButton acceptedButtons: Qt.LeftButton
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.ArrowCursor cursorShape: Qt.ArrowCursor
onContainsMouseChanged: if (containsMouse) root.setPeerCursor(peerRow.rowIndex) onContainsMouseChanged: if (containsMouse && peerRow.navigable) root.setPeerCursor(peerRow.rowIndex)
} }
RowLayout { RowLayout {
@@ -962,7 +1120,7 @@ Panel {
if (peerRow.peerDns !== "") parts.push(peerRow.peerDns) if (peerRow.peerDns !== "") parts.push(peerRow.peerDns)
return parts.join(" · ") return parts.join(" · ")
} }
color: root.dim color: root.accent
font.family: root.fontFamily font.family: root.fontFamily
font.pixelSize: Style.font.caption font.pixelSize: Style.font.caption
elide: Text.ElideRight elide: Text.ElideRight
+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 ## Features
- Shows Tailscale connection state in the bar - Shows Offscale connection state in the bar
- Left click opens a keyboard-friendly panel - Left click opens a keyboard-friendly panel
- Right click toggles Tailscale on/off - Right click toggles Offscale on/off
- Switch between available Tailscale connections when multiple are available - Switch between available Offscale connections when multiple are available
- Browse machines from `tailscale status --json` - 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 - Send files to a machine with Taildrop, when the tailnet allows file sharing
## Keyboard shortcuts ## Keyboard shortcuts
@@ -22,7 +22,7 @@ Inside the panel:
- `n`: copy selected peer name - `n`: copy selected peer name
- `d`: copy selected peer DNS name - `d`: copy selected peer DNS name
- `s`: send files to selected peer - `s`: send files to selected peer
- `t`: toggle Tailscale - `t`: toggle Offscale
- `r`: refresh status - `r`: refresh status
- `esc`: close - `esc`: close
@@ -37,12 +37,12 @@ Inside the panel:
Incoming Taildrop files are saved to `~/Downloads` by the Incoming Taildrop files are saved to `~/Downloads` by the
`omarchy-tailscale-receive` service, which announces each one with a `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 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. runs the same loop by hand.
## Icon ## 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 ## Add to the bar
+9 -9
View File
@@ -280,7 +280,7 @@ Item {
exitNodes = running ? tailnetExitNodes.concat(mullvadRegions) : [] exitNodes = running ? tailnetExitNodes.concat(mullvadRegions) : []
} }
function toggleTailscale() { function toggleOffscale() {
if (!installed) return if (!installed) return
if (active) down() if (active) down()
else loginOrUp() else loginOrUp()
@@ -304,7 +304,7 @@ Item {
} }
_loginOutput = "" _loginOutput = ""
_loginError = "" _loginError = ""
if (needsLogin) actionStatus = "Starting Tailscale login…" if (needsLogin) actionStatus = "Starting Offscale login…"
else _desired = 1 else _desired = 1
_loginInProgress = needsLogin _loginInProgress = needsLogin
_loginUrlOpened = false _loginUrlOpened = false
@@ -349,7 +349,7 @@ Item {
if (!installed || operatorProcess.running || userName === "") return if (!installed || operatorProcess.running || userName === "") return
_operatorOutput = "" _operatorOutput = ""
_operatorError = "" _operatorError = ""
actionStatus = "Authorizing Tailscale operator..." actionStatus = "Authorizing Offscale operator..."
operatorProcess.command = ["pkexec", "tailscale", "set", "--operator=" + userName] operatorProcess.command = ["pkexec", "tailscale", "set", "--operator=" + userName]
operatorProcess.running = true operatorProcess.running = true
} }
@@ -449,7 +449,7 @@ Item {
if (!root._loginInProgress || root._loginUrlOpened) return if (!root._loginInProgress || root._loginUrlOpened) return
if (!root.openAuthUrlFrom(root.authUrl, true)) { if (!root.openAuthUrlFrom(root.authUrl, true)) {
root._loginInProgress = false 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("") root.parseAccounts("")
if (/profiles access denied/i.test(stderr) || /profiles access denied/i.test(stdout)) { if (/profiles access denied/i.test(stderr) || /profiles access denied/i.test(stdout)) {
root.accountsAccessDenied = true root.accountsAccessDenied = true
root.lastError = "Authorize Tailscale operator to show connections" root.lastError = "Authorize Offscale operator to show connections"
} else { } 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 || "") var stderr = String(actionStderr.text || root._actionError || "")
if (exitCode !== 0) { if (exitCode !== 0) {
root._desired = -1 root._desired = -1
root.lastError = elideStatus(stderr || stdout || "Tailscale command failed") root.lastError = elideStatus(stderr || stdout || "Offscale command failed")
root.actionStatus = root.lastError root.actionStatus = root.lastError
actionStatusTimer.restart() actionStatusTimer.restart()
} else { } else {
@@ -621,13 +621,13 @@ Item {
var stdout = String(operatorStdout.text || root._operatorOutput || "") var stdout = String(operatorStdout.text || root._operatorOutput || "")
var stderr = String(operatorStderr.text || root._operatorError || "") var stderr = String(operatorStderr.text || root._operatorError || "")
if (exitCode !== 0) { if (exitCode !== 0) {
root.lastError = elideStatus(stderr || stdout || "Tailscale authorization failed") root.lastError = elideStatus(stderr || stdout || "Offscale authorization failed")
root.actionStatus = root.lastError root.actionStatus = root.lastError
actionStatusTimer.restart() actionStatusTimer.restart()
} else { } else {
root.accountsAccessDenied = false root.accountsAccessDenied = false
root.lastError = "" root.lastError = ""
root.actionStatus = "Tailscale operator authorized" root.actionStatus = "Offscale operator authorized"
actionStatusTimer.restart() actionStatusTimer.restart()
root._lastAccountsRefreshMs = 0 root._lastAccountsRefreshMs = 0
} }
+9 -6
View File
@@ -1,11 +1,11 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"id": "omarchy.tailscale", "id": "pastilhas.offscale",
"name": "Tailscale", "name": "Offscale",
"version": "1.0.0", "version": "1.0.0",
"author": "Omarchy", "author": "pastilhas",
"license": "MIT", "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": [ "kinds": [
"bar-widget" "bar-widget"
], ],
@@ -13,8 +13,8 @@
"barWidget": "Panel.qml" "barWidget": "Panel.qml"
}, },
"barWidget": { "barWidget": {
"displayName": "Tailscale", "displayName": "Offscale",
"description": "Toggle Tailscale, switch connections, browse machines, and copy machine IPs or names.", "description": "Toggle Offscale, switch connections, browse machines, and copy machine IPs or names.",
"category": "Network", "category": "Network",
"allowMultiple": false, "allowMultiple": false,
"defaultSection": "right", "defaultSection": "right",
@@ -32,5 +32,8 @@
"defaultValue": 30 "defaultValue": 30
} }
] ]
},
"omarchy": {
"clonedFrom": "omarchy.tailscale"
} }
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB