Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22d94428f6 | ||
|
|
92e284c11a | ||
|
|
54dec38b7e | ||
|
|
954d4dbcf0 | ||
|
|
04ac049a6f | ||
|
|
bbdf6cfa61 | ||
|
|
91cf1130e0 | ||
|
|
ca341c0e71 | ||
|
|
ad997bd6b1 | ||
|
|
b9bb75e8f5 | ||
|
|
0912b2b50d |
@@ -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 || "") : "",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
Reference in New Issue
Block a user