Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22d94428f6 | ||
|
|
92e284c11a | ||
|
|
54dec38b7e | ||
|
|
954d4dbcf0 | ||
|
|
04ac049a6f | ||
|
|
bbdf6cfa61 | ||
|
|
91cf1130e0 | ||
|
|
ca341c0e71 | ||
|
|
ad997bd6b1 | ||
|
|
b9bb75e8f5 | ||
|
|
0912b2b50d |
@@ -57,11 +57,25 @@ function osIcon(os) {
|
||||
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) {
|
||||
if (!account) return "Unknown account"
|
||||
if (account.nickname) return String(account.nickname)
|
||||
if (account.tailnet) return String(account.tailnet)
|
||||
if (account.account) return String(account.account)
|
||||
|
||||
var tailnet = String(account.tailnet || "").trim()
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -86,7 +100,7 @@ function hasFileSharing(self) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Tailscale grades every peer itself — offline, wrong owner, an OS without
|
||||
// Offscale grades every peer itself — offline, wrong owner, an OS without
|
||||
// Taildrop, no peer API — so take its word when the status carries one, and
|
||||
// fall back to same-owner for daemons too old to say.
|
||||
function isTaildropTarget(peer, selfUserId) {
|
||||
@@ -292,6 +306,19 @@ function parseAccounts(raw) {
|
||||
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 {
|
||||
accounts: next,
|
||||
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
|
||||
implicitHeight: iconSize
|
||||
|
||||
readonly property real dotSize: Math.max(2, root.iconSize * 0.24)
|
||||
readonly property real mid: (root.iconSize - dotSize) / 2
|
||||
readonly property real end: root.iconSize - dotSize
|
||||
|
||||
// Native rendering of the Tailscale mark from the SVG: a 3×3 dot grid
|
||||
// with the inactive dots faded. This avoids Qt SVG/effect rendering quirks
|
||||
// in tiny bar slots while keeping the official silhouette.
|
||||
Dot { x: 0; y: 0; opacity: 0.24 }
|
||||
Dot { x: root.mid; y: 0; opacity: 0.24 }
|
||||
Dot { x: root.end; y: 0; opacity: 0.24 }
|
||||
Dot { x: 0; y: root.mid; opacity: 1.0 }
|
||||
Dot { x: root.mid; y: root.mid; opacity: 1.0 }
|
||||
Dot { x: root.end; y: root.mid; opacity: 1.0 }
|
||||
Dot { x: 0; y: root.end; opacity: 0.24 }
|
||||
Dot { x: root.mid; y: root.end; opacity: 1.0 }
|
||||
Dot { x: root.end; y: root.end; opacity: 0.24 }
|
||||
// The Offscale mark, replacing the stock 3x3 Tailscale dot grid.
|
||||
//
|
||||
// offscale-logo.png lives beside this file rather than being referenced from
|
||||
// ~/projects, so the plugin stays self-contained and keeps working if that
|
||||
// tree moves. It is the source PNG trimmed of its transparent margin, padded
|
||||
// back to square so it centres, and scaled 1254 -> 256 (1.1 MB -> 77 KB).
|
||||
//
|
||||
// sourceSize caps the decode at 256 regardless of how large iconSize gets, so
|
||||
// the bar slot never decodes more pixels than it needs; mipmap keeps the
|
||||
// downscale to ~27px clean instead of aliased.
|
||||
Image {
|
||||
id: logo
|
||||
anchors.fill: parent
|
||||
source: Qt.resolvedUrl("offscale-logo.png")
|
||||
sourceSize.width: 256
|
||||
sourceSize.height: 256
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
mipmap: true
|
||||
// Dim the mark when Tailscale is down, matching how the stock icon faded
|
||||
// its inactive dots.
|
||||
opacity: root.crossed ? 0.45 : 1.0
|
||||
}
|
||||
|
||||
// Diagonal strike when disconnected.
|
||||
Rectangle {
|
||||
visible: root.crossed
|
||||
anchors.centerIn: parent
|
||||
@@ -43,6 +51,7 @@ Item {
|
||||
rotation: -45
|
||||
}
|
||||
|
||||
// "!" badge when a login is needed.
|
||||
BorderSurface {
|
||||
visible: root.warning
|
||||
width: Math.max(7, parent.width * 0.42)
|
||||
@@ -62,11 +71,4 @@ Item {
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
component Dot: Rectangle {
|
||||
width: root.dotSize
|
||||
height: root.dotSize
|
||||
radius: width / 2
|
||||
color: root.color
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
id: root
|
||||
moduleName: "omarchy.tailscale"
|
||||
ipcTarget: "omarchy.tailscale"
|
||||
moduleName: "pastilhas.offscale"
|
||||
ipcTarget: "pastilhas.offscale"
|
||||
manageIpc: false
|
||||
|
||||
property string focusSection: "header"
|
||||
@@ -22,24 +22,26 @@ Panel {
|
||||
property bool copyMenuOpen: false
|
||||
property bool mullvadPickerOpen: false
|
||||
property string mullvadQuery: ""
|
||||
property int phraseIndex: 0
|
||||
readonly property var activePhrases: [
|
||||
"Encrypting connections",
|
||||
"Sending secrets",
|
||||
"Guarding wires",
|
||||
"Braiding packets",
|
||||
"Polishing tunnels",
|
||||
"Hiding routes",
|
||||
"Sealing ports",
|
||||
"Sorting tailnets",
|
||||
"Shuffling keys",
|
||||
"Watching machines"
|
||||
]
|
||||
readonly property string heroPhraseText: activePhrases[phraseIndex % activePhrases.length]
|
||||
|
||||
// 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 urgent: bar ? bar.urgent : Color.urgent
|
||||
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 bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied
|
||||
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.
|
||||
readonly property bool headerHasCursor: cursorActive && focusSection === "header" && tailscale.installed
|
||||
readonly property color iconColor: tailscale.active ? foreground : dim
|
||||
readonly property string toggleHint: tailscale.active ? "Turn Tailscale off" : (tailscale.needsLogin ? "Authorize this device" : "Turn Tailscale on")
|
||||
readonly property string toggleHint: tailscale.active ? "Turn Offscale off" : (tailscale.needsLogin ? "Authorize this device" : "Turn Offscale on")
|
||||
readonly property color barIconColor: tailscale.active ? barForeground : Qt.darker(barForeground, 1.55)
|
||||
readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent"
|
||||
readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent"
|
||||
@@ -240,7 +242,7 @@ Panel {
|
||||
function activateCursor() {
|
||||
ensureCursor()
|
||||
if (focusSection === "header") {
|
||||
tailscale.toggleTailscale()
|
||||
tailscale.toggleOffscale()
|
||||
} else if (focusSection === "auth") {
|
||||
tailscale.authorizeTailscaleOperator()
|
||||
} else if (focusSection === "accounts") {
|
||||
@@ -265,24 +267,34 @@ Panel {
|
||||
if (region) chooseExitNode(region)
|
||||
}
|
||||
|
||||
function scrollItemIntoView(item) {
|
||||
if (!panelFlick || !item) return
|
||||
// `flick` defaults to the whole-panel scroller. The machine list has its own
|
||||
// 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() {
|
||||
if (!item) return
|
||||
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 bottom = top + item.height
|
||||
var viewTop = panelFlick.contentY
|
||||
var viewBottom = viewTop + panelFlick.height
|
||||
var maxY = Math.max(0, panelFlick.contentHeight - panelFlick.height)
|
||||
if (top < viewTop + margin) panelFlick.contentY = Math.max(0, top - margin)
|
||||
else if (bottom > viewBottom - margin) panelFlick.contentY = Math.min(maxY, bottom + margin - panelFlick.height)
|
||||
var viewTop = view.contentY
|
||||
var viewBottom = viewTop + view.height
|
||||
var maxY = Math.max(0, view.contentHeight - view.height)
|
||||
if (top < viewTop + margin) view.contentY = Math.max(0, top - margin)
|
||||
else if (bottom > viewBottom - margin) view.contentY = Math.min(maxY, bottom + margin - view.height)
|
||||
})
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -334,8 +346,31 @@ Panel {
|
||||
headerIndex = 0
|
||||
}
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
implicitWidth: barContent.implicitWidth
|
||||
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) {
|
||||
cursorActive = false
|
||||
@@ -373,19 +408,63 @@ Panel {
|
||||
function refresh(): string { tailscale.refresh(); return "ok" }
|
||||
function up(): string { tailscale.loginOrUp(); return "ok" }
|
||||
function down(): string { tailscale.down(); return "ok" }
|
||||
function toggleTailscale(): string { tailscale.toggleTailscale(); return "ok" }
|
||||
function toggleOffscale(): string { tailscale.toggleOffscale(); return "ok" }
|
||||
function status(): string { return tailscale.statusText }
|
||||
}
|
||||
|
||||
// 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 {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
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 {
|
||||
Item {
|
||||
TailscaleIcon {
|
||||
OffscaleIcon {
|
||||
anchors.centerIn: parent
|
||||
iconSize: Style.space(11)
|
||||
// Stock was space(11) = 18px at base-size 20 — noticeably smaller
|
||||
// than the neighbouring widgets. space(19) ~= 32px, inside the
|
||||
// widened canvas above. centerIn means it grows from the centre.
|
||||
iconSize: Style.space(16)
|
||||
color: root.barIconColor
|
||||
badgeColor: root.urgent
|
||||
crossed: !tailscale.active && !tailscale.needsLogin
|
||||
@@ -394,11 +473,12 @@ Panel {
|
||||
}
|
||||
}
|
||||
onPressed: function(buttonCode) {
|
||||
if (buttonCode === Qt.RightButton) tailscale.toggleTailscale()
|
||||
if (buttonCode === Qt.RightButton) tailscale.toggleOffscale()
|
||||
else if (buttonCode === Qt.MiddleButton) tailscale.refresh()
|
||||
else root.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardPanel {
|
||||
id: panel
|
||||
@@ -422,7 +502,7 @@ Panel {
|
||||
onCloseRequested: root.close()
|
||||
onTabRequested: function(direction) { root.switchPanel(direction) }
|
||||
onTextKey: function(t) {
|
||||
if (t === "t" || t === "T") tailscale.toggleTailscale()
|
||||
if (t === "t" || t === "T") tailscale.toggleOffscale()
|
||||
else if (t === "c" || t === "C") tailscale.copyPeerIp(root.selectedPeer())
|
||||
else if (t === "n" || t === "N") tailscale.copyPeerName(root.selectedPeer())
|
||||
else if (t === "d" || t === "D") tailscale.copyPeerDnsName(root.selectedPeer())
|
||||
@@ -454,17 +534,46 @@ Panel {
|
||||
readonly property bool ringVisible: root.headerHasCursor
|
||||
function focusHero() { root.setHeaderCursor() }
|
||||
|
||||
PanelHero {
|
||||
OffscaleHero {
|
||||
id: hero
|
||||
width: parent.width
|
||||
title: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale"
|
||||
meta: tailscale.active ? root.heroPhraseText : "Tailscale is disconnected"
|
||||
// The wordmark replaces the title text; `title` is kept as the
|
||||
// fallback string. The machine name moves down into `meta`, where
|
||||
// the cycling phrases used to be.
|
||||
title: tailscale.installed ? (tailscale.selfName || "Offscale") : "Offscale"
|
||||
titleComponent: Component {
|
||||
Image {
|
||||
source: Qt.resolvedUrl("offscale-text.png")
|
||||
// Asset is 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
|
||||
fontFamily: root.fontFamily
|
||||
iconOpacity: tailscale.active ? 1.0 : 0.5
|
||||
// Status only — the switch owns toggling, mouse and keyboard alike.
|
||||
iconComponent: Component {
|
||||
TailscaleIcon {
|
||||
OffscaleIcon {
|
||||
iconSize: Style.font.display
|
||||
color: root.iconColor
|
||||
badgeColor: root.urgent
|
||||
@@ -477,15 +586,21 @@ Panel {
|
||||
// header's only cursor target. The service already flips `active`
|
||||
// optimistically, so the knob throws the instant you click it.
|
||||
trailingControl: Component {
|
||||
ToggleSwitch {
|
||||
OffscaleToggle {
|
||||
id: powerSwitch
|
||||
visible: tailscale.installed
|
||||
checked: tailscale.active
|
||||
busy: tailscale.busy
|
||||
hasCursor: header.ringVisible
|
||||
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() }
|
||||
onToggled: tailscale.toggleTailscale()
|
||||
onToggled: tailscale.toggleOffscale()
|
||||
|
||||
PanelToolTip {
|
||||
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 {
|
||||
visible: tailscale.actionStatus !== "" || tailscale.lastError !== ""
|
||||
width: parent.width
|
||||
@@ -519,7 +664,7 @@ Panel {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.margins: Style.space(12)
|
||||
text: "Tailscale CLI is not installed or not on PATH."
|
||||
text: "Offscale CLI is not installed or not on PATH."
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
@@ -690,20 +835,42 @@ Panel {
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
id: peerColumn
|
||||
// The machine list scrolls on its own rather than pushing the
|
||||
// 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
|
||||
width: parent.width
|
||||
spacing: Style.space(6)
|
||||
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 }
|
||||
|
||||
Repeater {
|
||||
model: tailscale.peers
|
||||
PeerRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: peerColumn.width
|
||||
peer: modelData
|
||||
rowIndex: index
|
||||
Column {
|
||||
id: peerColumn
|
||||
width: peerFlick.width
|
||||
spacing: Style.space(6)
|
||||
|
||||
Repeater {
|
||||
model: tailscale.peers
|
||||
PeerRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: peerColumn.width
|
||||
peer: modelData
|
||||
rowIndex: index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -713,28 +880,7 @@ Panel {
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: phraseTimer
|
||||
interval: 2800
|
||||
running: root.opened && tailscale.active
|
||||
repeat: true
|
||||
onTriggered: phraseSwap.restart()
|
||||
}
|
||||
|
||||
SequentialAnimation {
|
||||
id: phraseSwap
|
||||
PropertyAnimation {
|
||||
target: hero; property: "metaOpacity"
|
||||
to: 0.0; duration: 180; easing.type: Easing.OutQuad
|
||||
}
|
||||
ScriptAction {
|
||||
script: root.phraseIndex = (root.phraseIndex + 1) % root.activePhrases.length
|
||||
}
|
||||
PropertyAnimation {
|
||||
target: hero; property: "metaOpacity"
|
||||
to: 1.0; duration: 260; easing.type: Easing.InQuad
|
||||
}
|
||||
}
|
||||
|
||||
component AuthRow: CursorSurface {
|
||||
id: authRow
|
||||
@@ -776,7 +922,7 @@ Panel {
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Authorize Tailscale operator"
|
||||
text: "Authorize Offscale operator"
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
@@ -785,7 +931,7 @@ Panel {
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Allow this user to operate this Tailscale profile"
|
||||
text: "Allow this user to operate this Offscale profile"
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
@@ -808,7 +954,12 @@ Panel {
|
||||
current: selectedAccount
|
||||
foreground: root.foreground
|
||||
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
|
||||
|
||||
@@ -842,7 +993,9 @@ Panel {
|
||||
|
||||
Text {
|
||||
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.pixelSize: Style.font.body
|
||||
font.bold: accountRow.selectedAccount
|
||||
@@ -882,7 +1035,12 @@ Panel {
|
||||
}
|
||||
property int copyIndex: 0
|
||||
|
||||
hasCursor: root.cursorActive && root.focusSection === "peers" && root.peerIndex === rowIndex
|
||||
// Peers live in the keyboard-navigable "peers" section. The local machine
|
||||
// row reuses this component purely for its copy menu, and must not claim
|
||||
// the peers cursor or renumber it on hover — hence navigable: false there.
|
||||
property bool navigable: true
|
||||
|
||||
hasCursor: navigable && root.cursorActive && root.focusSection === "peers" && root.peerIndex === rowIndex
|
||||
foreground: root.foreground
|
||||
|
||||
implicitHeight: Math.max(peerContent.implicitHeight, copyButton.implicitHeight) + Style.spacing.rowPaddingX
|
||||
@@ -921,7 +1079,7 @@ Panel {
|
||||
acceptedButtons: Qt.LeftButton
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.ArrowCursor
|
||||
onContainsMouseChanged: if (containsMouse) root.setPeerCursor(peerRow.rowIndex)
|
||||
onContainsMouseChanged: if (containsMouse && peerRow.navigable) root.setPeerCursor(peerRow.rowIndex)
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
@@ -962,7 +1120,7 @@ Panel {
|
||||
if (peerRow.peerDns !== "") parts.push(peerRow.peerDns)
|
||||
return parts.join(" · ")
|
||||
}
|
||||
color: root.dim
|
||||
color: root.accent
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
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
|
||||
|
||||
- Shows Tailscale connection state in the bar
|
||||
- Shows Offscale connection state in the bar
|
||||
- Left click opens a keyboard-friendly panel
|
||||
- Right click toggles Tailscale on/off
|
||||
- Switch between available Tailscale connections when multiple are available
|
||||
- Right click toggles Offscale on/off
|
||||
- Switch between available Offscale connections when multiple are available
|
||||
- Browse machines from `tailscale status --json`
|
||||
- Copy a machine's Tailscale IP, host name, or DNS name
|
||||
- Copy a machine's Offscale IP, host name, or DNS name
|
||||
- Send files to a machine with Taildrop, when the tailnet allows file sharing
|
||||
|
||||
## Keyboard shortcuts
|
||||
@@ -22,7 +22,7 @@ Inside the panel:
|
||||
- `n`: copy selected peer name
|
||||
- `d`: copy selected peer DNS name
|
||||
- `s`: send files to selected peer
|
||||
- `t`: toggle Tailscale
|
||||
- `t`: toggle Offscale
|
||||
- `r`: refresh status
|
||||
- `esc`: close
|
||||
|
||||
@@ -37,12 +37,12 @@ Inside the panel:
|
||||
Incoming Taildrop files are saved to `~/Downloads` by the
|
||||
`omarchy-tailscale-receive` service, which announces each one with a
|
||||
notification (an image preview when the file is an image, and a click to open
|
||||
it). The Tailscale service install enables it; `omarchy tailscale receive`
|
||||
it). The Offscale service install enables it; `omarchy tailscale receive`
|
||||
runs the same loop by hand.
|
||||
|
||||
## Icon
|
||||
|
||||
Renders the Tailscale mark natively as a theme-colored 3×3 dot grid, matching the official SVG silhouette while avoiding tiny-SVG rendering quirks in the bar.
|
||||
Renders the Offscale mark natively as a theme-colored 3×3 dot grid, matching the official SVG silhouette while avoiding tiny-SVG rendering quirks in the bar.
|
||||
|
||||
## Add to the bar
|
||||
|
||||
|
||||
+9
-9
@@ -280,7 +280,7 @@ Item {
|
||||
exitNodes = running ? tailnetExitNodes.concat(mullvadRegions) : []
|
||||
}
|
||||
|
||||
function toggleTailscale() {
|
||||
function toggleOffscale() {
|
||||
if (!installed) return
|
||||
if (active) down()
|
||||
else loginOrUp()
|
||||
@@ -304,7 +304,7 @@ Item {
|
||||
}
|
||||
_loginOutput = ""
|
||||
_loginError = ""
|
||||
if (needsLogin) actionStatus = "Starting Tailscale login…"
|
||||
if (needsLogin) actionStatus = "Starting Offscale login…"
|
||||
else _desired = 1
|
||||
_loginInProgress = needsLogin
|
||||
_loginUrlOpened = false
|
||||
@@ -349,7 +349,7 @@ Item {
|
||||
if (!installed || operatorProcess.running || userName === "") return
|
||||
_operatorOutput = ""
|
||||
_operatorError = ""
|
||||
actionStatus = "Authorizing Tailscale operator..."
|
||||
actionStatus = "Authorizing Offscale operator..."
|
||||
operatorProcess.command = ["pkexec", "tailscale", "set", "--operator=" + userName]
|
||||
operatorProcess.running = true
|
||||
}
|
||||
@@ -449,7 +449,7 @@ Item {
|
||||
if (!root._loginInProgress || root._loginUrlOpened) return
|
||||
if (!root.openAuthUrlFrom(root.authUrl, true)) {
|
||||
root._loginInProgress = false
|
||||
root.actionStatus = "Tailscale login link not available yet"
|
||||
root.actionStatus = "Offscale login link not available yet"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -500,9 +500,9 @@ Item {
|
||||
root.parseAccounts("")
|
||||
if (/profiles access denied/i.test(stderr) || /profiles access denied/i.test(stdout)) {
|
||||
root.accountsAccessDenied = true
|
||||
root.lastError = "Authorize Tailscale operator to show connections"
|
||||
root.lastError = "Authorize Offscale operator to show connections"
|
||||
} else {
|
||||
root.lastError = elideStatus(stderr || stdout || "Could not list Tailscale connections")
|
||||
root.lastError = elideStatus(stderr || stdout || "Could not list Offscale connections")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -532,7 +532,7 @@ Item {
|
||||
var stderr = String(actionStderr.text || root._actionError || "")
|
||||
if (exitCode !== 0) {
|
||||
root._desired = -1
|
||||
root.lastError = elideStatus(stderr || stdout || "Tailscale command failed")
|
||||
root.lastError = elideStatus(stderr || stdout || "Offscale command failed")
|
||||
root.actionStatus = root.lastError
|
||||
actionStatusTimer.restart()
|
||||
} else {
|
||||
@@ -621,13 +621,13 @@ Item {
|
||||
var stdout = String(operatorStdout.text || root._operatorOutput || "")
|
||||
var stderr = String(operatorStderr.text || root._operatorError || "")
|
||||
if (exitCode !== 0) {
|
||||
root.lastError = elideStatus(stderr || stdout || "Tailscale authorization failed")
|
||||
root.lastError = elideStatus(stderr || stdout || "Offscale authorization failed")
|
||||
root.actionStatus = root.lastError
|
||||
actionStatusTimer.restart()
|
||||
} else {
|
||||
root.accountsAccessDenied = false
|
||||
root.lastError = ""
|
||||
root.actionStatus = "Tailscale operator authorized"
|
||||
root.actionStatus = "Offscale operator authorized"
|
||||
actionStatusTimer.restart()
|
||||
root._lastAccountsRefreshMs = 0
|
||||
}
|
||||
|
||||
+9
-6
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "omarchy.tailscale",
|
||||
"name": "Tailscale",
|
||||
"id": "pastilhas.offscale",
|
||||
"name": "Offscale",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"author": "pastilhas",
|
||||
"license": "MIT",
|
||||
"description": "Tailscale status, connection switching, machine browsing, and quick copy actions in the Omarchy bar.",
|
||||
"description": "Offscale status, connection switching, machine browsing, and quick copy actions in the Omarchy bar.",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
@@ -13,8 +13,8 @@
|
||||
"barWidget": "Panel.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Tailscale",
|
||||
"description": "Toggle Tailscale, switch connections, browse machines, and copy machine IPs or names.",
|
||||
"displayName": "Offscale",
|
||||
"description": "Toggle Offscale, switch connections, browse machines, and copy machine IPs or names.",
|
||||
"category": "Network",
|
||||
"allowMultiple": false,
|
||||
"defaultSection": "right",
|
||||
@@ -32,5 +32,8 @@
|
||||
"defaultValue": 30
|
||||
}
|
||||
]
|
||||
},
|
||||
"omarchy": {
|
||||
"clonedFrom": "omarchy.tailscale"
|
||||
}
|
||||
}
|
||||
|
||||
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