Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
938 changes: 938 additions & 0 deletions Account.qml

Large diffs are not rendered by default.

80 changes: 61 additions & 19 deletions BarWidget.qml
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,35 @@ BarWidget {
property bool ipcRegistrationReady: false

readonly property var service: panelLoader.item ? panelLoader.item.service : null
readonly property bool active: service ? service.active : false
readonly property bool syncing: service ? service.syncing : false
readonly property bool starting: service ? service.activeState === "activating" : false
readonly property bool installed: service ? service.installed : false
readonly property bool authenticated: service ? service.authenticated : false
readonly property bool attention: service
? service.serviceFailed || service.resyncRequired || service.reauthRequired
: false

// Worst of N. With one account this resolves to exactly the state the old
// flat ternary produced; the mapping and the precedence are table-tested in
// tests/Aggregate.test.js rather than spelled out here.
readonly property var aggregate: service
? service.aggregate
: ({ kind: "checking", count: 0, anyActive: false, initialized: false })
readonly property int accountCount: service ? service.accountCount : 0

// Lit/dim/spinning is decided in Model.js, not here: nothing in this file can
// be instantiated by a test, so an expression written inline is one no
// assertion can reach. tests/Aggregate.test.js pins the rules.
readonly property var barState: Model.barState(aggregate)
readonly property bool active: barState.active
readonly property bool syncing: barState.syncing
readonly property bool installed: barState.installed
readonly property color iconColor: active
? (bar ? bar.barForeground : Color.foreground)
: Qt.darker(bar ? bar.barForeground : Color.foreground, 1.55)
readonly property string badgeKind: !installed ? "missing"
: (!authenticated ? "login"
: (attention ? "attention"
: (syncing || starting ? "syncing"
: (!active ? "paused" : ""))))
readonly property string badgeGlyph: badgeKind === "missing" ? "󰅖"
: (badgeKind === "login" ? "󰌋"
: (badgeKind === "paused" ? "󰏤"
: (badgeKind === "syncing" ? "󰑓"
: (badgeKind === "attention" ? "󰀪" : ""))))
// The worst account's kind, pause included: every account has to be working
// before the bar looks normal. See the note in Model.aggregateAccounts.
readonly property string badgeKind: Model.badgeKind(aggregate.kind)
readonly property string badgeGlyph: Model.badgeGlyph(badgeKind)
readonly property color badgeColor: badgeKind === "login" || badgeKind === "attention"
? (bar ? bar.urgent : Color.urgent)
: (badgeKind === "syncing" ? Color.accent : iconColor)
readonly property color badgeBackground: bar ? bar.background : Color.background
readonly property string tooltipText: service
? Model.tooltip(service, Date.now())
? Model.aggregateTooltip(service.accounts, Date.now())
: "Checking OneDrive…"
readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false
readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false
Expand Down Expand Up @@ -135,6 +137,40 @@ BarWidget {
return "ok"
}
function status(): string { return root.service ? root.service.statusText : "Checking…" }
// Enumerate and select, so automation can reach a non-default account before
// invoking any of the controls above -- which all act on the selected one.
// Both bodies live in Model.js: this file cannot be instantiated headless,
// so anything inline here is untestable.
function accounts(): string {
if (!root.service) return "[]"
return JSON.stringify(Model.accountRows(root.service.accounts, root.service.selectedService))
}
function selectAccount(target: string): string {
if (!root.service) return "no accounts"
var found = Model.resolveAccountTarget(root.service.accounts, target)
if (found === "") return "unknown account: " + target
// false: automation selecting an account merely to target a control must
// not trigger the panel's stale-quota retry, which contacts Microsoft.
root.service.selectAccount(found, false)
return "ok"
}
// Notification clicks land here: the daemon persists the popup's --exec
// hint and runs `omarchy-shell <target> openAccount <service>` on click,
// which works even after this process has been reloaded. Behaviour lives
// in Service.qml so the harness can drive it.
function openAccount(target: string): string {
// Open FIRST: Panel.open() runs selectBadgedAccount, which may move the
// selection to whatever the badge is about. The popup's account is the
// user's explicit choice and has to land last, or clicking a reauth
// popup for Work opened the panel on whichever account was syncing.
root.open()
if (root.service) root.service.openFromNotification(target)
return "ok"
}
function repairAccount(target: string): string {
if (root.service) root.service.repairFromNotification(target)
return "ok"
}
}

BarIconButton {
Expand Down Expand Up @@ -184,7 +220,13 @@ BarWidget {
}
}
}
// Every gesture points at the account the badge is about before acting.
// Only left-click did, so middle-click opened the folder of whichever
// account happened to be selected -- at cold boot, the first discovered one
// -- while the badge was about another, and right-click spent the single
// 30-second cloud slot on the wrong account.
onPressed: function(buttonCode) {
if (root.service) root.service.selectBadgedAccount()
if (buttonCode === Qt.RightButton && root.service) root.service.checkQuota()
else if (buttonCode === Qt.MiddleButton && root.service) root.service.openFolder()
else root.togglePanel()
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
# Changelog

## 1.6.0 - 2026-09-02

- Discover every configured OneDrive account -- the plain service and any
`onedrive@<instance>` template units -- and show them all: one badge
aggregated worst-first across the fleet, per-account tabs in the panel, and
every action (pause, timed pause, resume, reauthentication, resync repair,
folder, storage) running against the selected account's own service, config
directory, and resume timer. A machine with one account keeps exactly the
previous behaviour, commands included.
- Poll accounts round-robin on one shared budget with a startup ramp, run at
most one cloud check at a time across the fleet, and coalesce each polling
burst into a single desktop notification that names its account and opens
the one it is about.
- Add `accounts` and `selectAccount` IPC functions so scripts can target a
specific account; notification clicks use the new `openAccount` and
`repairAccount` functions with the account carried in the persisted exec
hint.
- Survive a helper, `systemctl`, or `systemd-run` that fails to start, hangs,
or exits without reporting: every spawned process settles exactly once, with
watchdogs, so one wedged command can no longer freeze polling or disable
Pause for the session.
- Stamp every helper reply with the config directory it actually read and
refuse mismatches, so the startup poll can never attach one account's data
to another account's name.

## 1.5.6 - 2026-08-31

- Make actionable notifications compatible with Omarchy 4.0.1 by placing
Expand Down
166 changes: 166 additions & 0 deletions Commands.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Command vectors for one OneDrive account.
//
// Every function returns an argv array. Nothing here is ever passed through a
// shell, and no value is interpolated into a string that becomes a command --
// that is the invariant `docs/ARCHITECTURE.md` states and that
// `tests/Commands.test.js` pins with exact-array assertions.
//
// Each account is identified by its systemd service, its config directory, and
// the instance parsed out of the service name. Nothing is derived from a naming
// convention: the confdir comes from the helper's discovery, which reads it out
// of each unit's own ExecStart.

var DEFAULT_SERVICE = "onedrive.service"
var DEFAULT_RESUME_UNIT = "omaonedrive-resume"
// Must match BarWidget.qml's moduleName: notification clicks are delivered by
// Omarchy's notification daemon running `omarchy-shell <target> <function>`,
// long after this process may have restarted. tests/PanelWiring.test.js pins
// the two files against each other.
var IPC_TARGET = "io.github.salemsayed.omaonedrive"

// The plain service deliberately keeps the legacy unit name, so a timer that is
// already in flight survives an upgrade and stays visible and cancellable.
//
// Returns "" when no usable resume unit can be derived. The caller degrades to
// an untimed pause rather than scheduling a timer that would collide with
// another account's or that systemd would refuse.
function resumeUnit(instance) {
var value = String(instance || "")
if (value === "") return DEFAULT_RESUME_UNIT
// systemd-run reads a --unit value ending in a unit suffix as THAT unit, so
// instance "foo.timer" would derive the same timer as instance "foo" -- and
// cancelling it would target "…foo.timer.timer", which does not exist,
// stranding the other account's pause.
if (value.endsWith(".timer") || value.endsWith(".service")) return ""
var unit = DEFAULT_RESUME_UNIT + "@" + value
// systemd-run creates a .timer AND a .service, so the longer suffix is what
// has to fit. Checking only .timer let a 230-character instance stop the
// account and then fail to schedule its resume.
if (unit.length + ".service".length > 255) return ""
return unit
}

// The status call is account-complete: every invocation names the service, the
// config directory and the resume unit, so no part of the answer can be about a
// different account. `mode` is "" for the routine local poll, or "quota" /
// "sync-status" for an explicit cloud check.
function status(helperPath, account, recentFileLimit, mode) {
var command = ["python3", String(helperPath)]
// An empty value means "unspecified", not "empty string": the helper's own
// defaults are the single-account behaviour, so before discovery has supplied
// an identity this produces exactly the command the widget sends today.
var service = String(account.service || "")
var confdir = String(account.confdir || "")
var instance = String(account.instance || "")
if (service !== "" && service !== DEFAULT_SERVICE) {
command.push("--service", service)
}
if (confdir !== "") command.push("--confdir", confdir)
// A non-default account with no confdir must not be polled at all. The helper
// independently re-derives the confdir in that case, so this is belt-and-
// braces -- but relying on that means the widget silently reports the DEFAULT
// account's token, quota and files under this account's name if the helper
// guard is ever relaxed. Refuse instead, and let the caller show nothing.
else if (service !== "" && service !== DEFAULT_SERVICE) return []
var unit = instance === "" ? "" : resumeUnit(instance)
if (unit !== "") command.push("--resume-unit", unit)
command.push("--limit", String(recentFileLimit))
if (mode === "quota") command.push("--quota")
else if (mode === "sync-status") command.push("--sync-status")
return command
}

function listAccounts(helperPath) {
return ["python3", String(helperPath), "--list-accounts"]
}

function control(action, service) {
return ["systemctl", "--user", String(action), String(service)]
}

// The interactive CLI flows are the only place --resync may appear, and only
// through omarchy-launch-terminal, where the client prompts for confirmation.
function login(confdir, mode) {
var command = ["omarchy-launch-terminal", "onedrive"]
// Same rule as status(): an unspecified confdir means the client's own
// default, not an empty string. Passing "--confdir ''" would hand the CLI a
// value it must reject, and this path is reachable before discovery has
// supplied an identity.
if (String(confdir || "") !== "") command.push("--confdir", String(confdir))
if (mode === "reauth") command.push("--reauth")
else if (mode === "resync") {
command.push("--sync")
command.push("--resync")
}
return command
}

// Cancel only this account's timer and service; another account's pause is
// untouched.
function cancelResume(unit) {
return ["systemctl", "--user", "stop", unit + ".timer", unit + ".service"]
}

// On expiry the timer starts the SAME service the pause stopped.
function scheduleResume(unit, service, minutes) {
return [
"systemd-run", "--user",
"--unit=" + unit,
"--description=Resume OneDrive after timed pause",
"--on-active=" + String(minutes) + "m",
"--timer-property=AccuracySec=1s",
"--collect",
"/usr/bin/systemctl", "--user", "start", String(service)
]
}

// The click command a notification carries, as a closed argv set. Omarchy's
// notification daemon persists this and runs it on click, so it works after a
// shell or plugin reload -- unlike the old notify-send stdout tracking, which
// died with the process that spawned it. The account rides along as its own
// argv element; nothing user-controlled is ever joined into a command string.
function notificationExec(behavior, service) {
var target = String(service || "")
if (behavior === "open") {
if (target === "") return ["omarchy-shell", IPC_TARGET, "open"]
return ["omarchy-shell", IPC_TARGET, "openAccount", target]
}
if (behavior === "repair") {
if (target === "") return ["omarchy-shell", IPC_TARGET, "resync"]
return ["omarchy-shell", IPC_TARGET, "repairAccount", target]
}
return []
}

// omarchy-notification-send reads every argument after --exec as the click
// command, so the delimiter must come after the notification text and each
// command word must stay a separate array element.
function notify(urgency, summary, body, behavior, service) {
var command = [
"omarchy-notification-send", "--app-name", "OmaOneDrive",
"--urgency", String(urgency), String(summary), String(body)
]
var exec = notificationExec(behavior, service)
if (exec.length > 0) {
command.push("--exec")
for (var index = 0; index < exec.length; index++) command.push(exec[index])
}
return command
}

if (typeof module !== "undefined") {
module.exports = {
DEFAULT_SERVICE: DEFAULT_SERVICE,
DEFAULT_RESUME_UNIT: DEFAULT_RESUME_UNIT,
IPC_TARGET: IPC_TARGET,
notificationExec: notificationExec,
resumeUnit: resumeUnit,
status: status,
listAccounts: listAccounts,
control: control,
login: login,
cancelResume: cancelResume,
scheduleResume: scheduleResume,
notify: notify
}
}
Loading