diff --git a/Account.qml b/Account.qml new file mode 100644 index 0000000..b87eb70 --- /dev/null +++ b/Account.qml @@ -0,0 +1,938 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "Model.js" as Model +import "Commands.js" as Commands + +// One OneDrive account: its own systemd unit, config directory, resume timer, +// status process and control processes. Nothing here reads or writes another +// account's state, and every command vector is built from this object's own +// identity by Commands.js. +Item { + id: root + + // Identity, supplied by discovery. confdir is read out of this unit's own + // ExecStart by the helper -- never guessed from the instance name. + property string service: Commands.DEFAULT_SERVICE + property string instance: "" + property string confdir: "" + property string description: "" + + readonly property string displayName: Model.accountName(instance, description) + // Waiting to be told what a control actually did. The coordinator gives these + // the next poll slot: without that the answer could be swallowed indefinitely + // by a neighbour's polling, and the bar would keep showing pre-control state. + readonly property bool settling: settleTimer.waiting + // The config directory the displayed sample actually came from, as the helper + // reported it -- not the one we believe this unit uses. + property string sampleConfdir: "" + + // Discovery can tell us this unit's real directory AFTER a poll has already + // reported under a different one. That sample is another account's; nothing in + // it may stay on screen. + onConfdirChanged: { + if (sampleConfdir !== "" && confdir !== "" && sampleConfdir !== confdir) forgetSample() + } + readonly property string resumeUnit: Commands.resumeUnit(instance) + // True once a status poll has produced a usable sample. Until then this + // account contributes no state to the aggregate, so default values cannot + // flash a wrong badge. + property bool initialized: false + // True once a poll has been ATTEMPTED, whatever its outcome. An account whose + // helper always fails is never `initialized`, so this is what "the first round + // is over" must be measured with -- otherwise one broken account either + // freezes the aggregate forever or defeats the startup notification hold. + property bool attempted: false + // Incremented whenever this account's identity changes. A status reply carries + // the generation it was started under, so a reply from the previous config + // directory is discarded instead of overwriting the new one's state. + property int generation: 0 + property int _pendingGeneration: 0 + + property var settings: ({}) + property var coordinator: null + + property bool installed: false + property bool serviceAvailable: false + property bool running: false + property bool enabled: false + property string activeState: "" + property bool serviceFailed: false + property bool resyncRequired: false + property bool authenticated: false + property bool reauthRequired: false + property bool syncing: false + property string syncStage: "" + property int _desired: -1 + // Real Quickshell sets `running` false WITHOUT ever emitting `exited` when the + // executable cannot be started -- a missing python3, a helper deleted under + // us, a systemd-run that is not on PATH. Every one of these processes cleans + // up only in onExited, and the coordinator's one-poll-at-a-time gate hangs off + // `refreshing`, so a single unstartable command froze routine polling for + // EVERY account for the life of the session. Each process therefore settles on + // whichever signal arrives first, and settles exactly once. + // Per-INVOCATION, not a shared boolean. A deferred settle callback outlives + // the process that scheduled it: start a quota check, queue a sync-status for + // the same account, let the quota finish normally, and the quota's deferred + // callback then found `_statusSettled` false again -- because the sync-status + // had started -- and abandoned a process that was running perfectly well. + // Each start takes a new token; a callback acts only on its own. + property int _statusRun: 0 + property int _statusSettledRun: 0 + property int _controlRun: 0 + property int _controlSettledRun: 0 + property int _cancelRun: 0 + property int _cancelSettledRun: 0 + property int _scheduleRun: 0 + property int _scheduleSettledRun: 0 + readonly property bool _statusSettled: _statusSettledRun === _statusRun + // Polls are numbered as they START, and the number of the poll whose sample was + // last APPLIED is kept. Counting completions was not enough: a poll started + // BEFORE the control could complete after it and be accepted as the + // confirmation, which undid the optimistic pause with pre-control data -- + // exactly the revert the settle loop exists to prevent. + property int _pollsStarted: 0 + property int _pendingPoll: 0 + property int _confirmedPoll: 0 + readonly property bool active: _desired === -1 + ? (running || activeState === "activating") : _desired === 1 + property bool refreshing: false + // Distinct from `refreshing`: only a ROUTINE poll occupies the coordinator's + // one-at-a-time slot. A 30s cloud check must not freeze every account's + // routine polling. + readonly property bool routinePolling: refreshing && _activeCloudMode === "" + // Any status process at all -- routine or cloud. An account in this state + // cannot accept a poll slot, so the scheduler must skip it rather than + // spending a tick on a refresh() that returns immediately. + readonly property bool statusBusy: statusProcess.running + property string statusText: "Checking…" + property string syncDir: "" + property string syncMode: "Two-way" + property string clientVersion: "" + property double resumeAt: 0 + property double lastSyncTs: 0 + property double usedBytes: 0 + property double quotaBytes: 0 + property bool quotaKnown: false + property double quotaCheckedTs: 0 + property string quotaError: "" + property string remoteStatus: "Not checked" + property double syncStatusCheckedTs: 0 + property string syncStatusError: "" + property double remoteCheckedTs: 0 + property string remoteError: "" + property var files: [] + property var activity: [] + property string actionStatus: "" + property string lastError: "" + + readonly property bool notificationsEnabled: { + var value = setting("notifications", true) + return value === true || String(value).toLowerCase() === "true" + } + readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 30, 10, 3600) + readonly property int recentFileLimit: intSetting("recentFileLimit", 20, 5, 50) + readonly property string helperPath: Model.filePath(Qt.resolvedUrl("onedrive-status.py")) + readonly property bool busy: statusProcess.running || controlProcess.running + || cancelTimerProcess.running || scheduleTimerProcess.running + readonly property bool cloudChecking: _activeCloudMode !== "" && statusProcess.running + // A cloud check that is waiting for this account's routine poll to finish is + // already holding the shared slot, even though no process is running for it + // yet. Without this the coordinator saw "not busy" and started a second one. + readonly property bool cloudPending: _cloudRequested !== "" + // Which cloud mode this account occupies the shared slot with, running or + // merely deferred, so the coordinator can recognise a duplicate request. + readonly property string activeCloudMode: _activeCloudMode !== "" ? _activeCloudMode : _cloudRequested + readonly property bool quotaChecking: _activeCloudMode === "quota" && statusProcess.running + readonly property bool fullStatusChecking: _activeCloudMode === "sync-status" && statusProcess.running + + // Must match QUOTA_TIMEOUT_SECONDS / SYNC_STATUS_TIMEOUT_SECONDS in onedrive-status.py. + readonly property int cloudTimeoutSec: 30 + // How long a routine poll may run before it is abandoned, and how long the + // settle loop waits between asking for the result of a control. Both are + // settings rather than constants only so the test harness can drive them in + // milliseconds instead of minutes; nothing sets them in production. + readonly property int statusTimeoutMs: intSetting("statusTimeoutMs", + Math.max(60, cloudTimeoutSec * 2) * 1000, 200, 600000) + readonly property int settleIntervalMs: intSetting("settleIntervalMs", 1200, 20, 60000) + readonly property int cloudRetryAfterSec: 300 + + property string _cloudRequested: "" + property string _activeCloudMode: "" + property string _statusOutput: "" + property string _statusError: "" + property string _controlOutput: "" + property string _controlError: "" + property string _timerOutput: "" + property string _timerError: "" + property string _afterTimerCancel: "" + property int _pauseMinutes: 0 + property int _controlDesired: -1 + property bool _scheduleRecovery: false + + function setting(name, fallback) { + var value = settings ? settings[name] : undefined + return value === undefined || value === null ? fallback : value + } + + function intSetting(name, fallback, minimum, maximum) { + var value = parseInt(String(setting(name, fallback)), 10) + if (!isFinite(value)) value = fallback + return Math.max(minimum, Math.min(maximum, value)) + } + + // Drop everything derived from a previous config directory, keeping identity + // and in-flight processes. The next poll repopulates it. + // Discard whatever is in flight without touching what is on screen. + // + // The startup seed polls `onedrive.service` before discovery has told us its + // config directory, so that poll uses the CLIENT's default. If the unit's + // ExecStart names a different --confdir, the reply describes a different + // account: its sync directory, quota and token state would be applied under + // this one's name, and "Open folder" would open the wrong tree. Nothing is + // displayed yet at that point, so there is no sample worth forgetting -- only + // a reply worth refusing. + function discardInFlight() { + generation += 1 + } + + function forgetSample() { + // Any reply already in flight was started under the previous config + // directory; this makes onExited drop it. + generation += 1 + sampleConfdir = "" + initialized = false + // A new identity has not been polled yet, whatever the old one had done. + attempted = false + actionStatus = "" + // The displayed fields too: leaving these meant the panel showed the old + // account's status and "Open folder" opened the PREVIOUS account's sync + // directory -- the exact leak this function exists to prevent. + syncDir = "" + statusText = "Checking…" + syncStage = "" + syncMode = "Two-way" + clientVersion = "" + activeState = "" + installed = false + running = false + enabled = false + syncing = false + serviceAvailable = false + resumeAt = 0 + files = [] + activity = [] + quotaKnown = false + usedBytes = 0 + quotaBytes = 0 + quotaCheckedTs = 0 + quotaError = "" + remoteStatus = "Not checked" + syncStatusCheckedTs = 0 + syncStatusError = "" + remoteCheckedTs = 0 + remoteError = "" + lastError = "" + lastSyncTs = 0 + // Edge latches too: a condition that was true for the old directory must be + // able to notify again for the new one. + authenticated = false + serviceFailed = false + resyncRequired = false + reauthRequired = false + accountStateChanged() + } + + function refresh(remote) { + if (remote === true) { + checkQuota() + return + } + if (statusProcess.running || helperPath === "") return + startStatusProcess("") + } + + // Internal follow-up refreshes -- after a control command settles, or a + // delayed re-read -- go through the coordinator's shared slot like everything + // else. Calling refresh() directly from those timers started a second helper + // while another account was mid-poll. + function requestRefresh() { + if (coordinator && coordinator.routinePollRunning()) return + refresh(false) + } + + function checkQuota() { + requestCloud("quota") + } + + // Opening the panel is explicit user intent, so a failed storage result + // older than cloudRetryAfterSec is retried once on open. The decision is + // deferred until the next status poll returns, because at open() time the + // in-memory state may predate the poll the panel just started. + // quotaCheckedTs updates even on failure, which blocks another retry until + // the window passes. Verify sync is never retried automatically — it is + // the expensive full-drive check and stays strictly manual. + property bool _quotaRetryQueued: false + + function retryStaleQuotaOnOpen() { + _quotaRetryQueued = true + } + + function maybeRetryStaleQuota() { + if (quotaError === "" || quotaChecking) return + if (Date.now() / 1000 - quotaCheckedTs < cloudRetryAfterSec) return + checkQuota() + } + + function checkFullStatus() { + requestCloud("sync-status") + } + + // Cloud checks are slow and shared: the coordinator serialises them across + // every account so two 30s checks cannot run at once. Without a coordinator + // this account serves itself, which keeps Account usable on its own. + function requestCloud(mode) { + if (helperPath === "") return + if (coordinator) { + coordinator.requestCloud(root, mode) + return + } + startCloudCheck(mode) + } + + function startCloudCheck(mode) { + if (helperPath === "") return + if (statusProcess.running) { + _cloudRequested = mode + return + } + startStatusProcess(mode) + } + + function startStatusProcess(cloudMode) { + var command = Commands.status(helperPath, root, recentFileLimit, cloudMode) + if (command.length === 0) { + // This account cannot be described (a non-default service with no known + // config directory). Starting a Process on an empty command would never + // exit, so `refreshing` would stay true forever and the coordinator's + // one-poll-at-a-time gate would freeze EVERY account permanently. + attempted = true + lastError = "This account's configuration directory is unknown" + accountStateChanged() + pollFinished(root.service) + return + } + _activeCloudMode = cloudMode + _statusOutput = "" + _statusError = "" + _pendingGeneration = generation + _pollsStarted += 1 + _pendingPoll = _pollsStarted + _statusRun += 1 + statusWatchdog.restart() + refreshing = true + if (cloudMode !== "") { + actionStatusTimer.stop() + actionStatus = (cloudMode === "quota" ? "Refreshing storage" : "Verifying sync") + + "… may take up to " + String(cloudTimeoutSec) + "s" + } + statusProcess.command = command + statusProcess.running = true + } + + signal pollFinished(string service) + signal transition(var event) + // NOT named stateChanged: QQuickItem already has that as the NOTIFY signal for + // `state`, so declaring it is an invalid override -- Qt warns once per account + // per start and Item.state loses its change notification. qmllint does not + // catch it. The design doc named it stateChanged; this is a deliberate + // deviation. + signal accountStateChanged() + + // Transitions are reported, not delivered. The coordinator batches whatever + // arrives in one polling burst into at most one desktop notification, so three + // accounts going wrong together do not produce three popups. + function report(kind, summary, short, body, action) { + transition({ + service: root.service, + name: displayName, + kind: kind, + summary: summary, + short: short, + body: body, + action: action || "" + }) + } + + function applyStatus(raw) { + var parsed = Model.parseStatus(raw) + if (!parsed.ok) { + lastError = parsed.lastError || "Failed to read OneDrive status" + return + } + // The helper reports which config directory it actually read. A reply from a + // different one describes a different account, and applying it would put + // that account's sync directory, quota, token state and file list under this + // account's name -- which is what the startup seed poll did whenever a unit + // overrode the client's default directory. + var reported = String(parsed.confdir || "") + if (reported !== "" && confdir !== "" && reported !== confdir) { + // Say so. A silent return leaves the account permanently on "Checking…" + // with nothing to explain it, and if the two directories ever disagree for + // a reason other than the startup race -- a normalisation difference, say + // -- that is a bug that must be visible rather than a mystery. + lastError = "OneDrive status came from " + reported + ", not " + confdir + return + } + var wasFailed = serviceFailed + var wasResync = resyncRequired + var wasReauth = reauthRequired + var hadAttention = serviceFailed || resyncRequired || reauthRequired + var wasStorageSevere = Model.usageSevere(usedBytes, quotaBytes, quotaKnown) + installed = parsed.installed === true + serviceAvailable = parsed.serviceAvailable === true + running = parsed.running === true + enabled = parsed.enabled === true + activeState = String(parsed.activeState || "") + serviceFailed = parsed.serviceFailed === true + resyncRequired = parsed.resyncRequired === true + authenticated = parsed.authenticated === true + reauthRequired = parsed.reauthRequired === true + syncing = parsed.syncing === true + syncStage = String(parsed.syncStage || "") + _confirmedPoll = _pendingPoll + sampleConfdir = reported + if (_desired !== -1 && running === (_desired === 1)) _desired = -1 + statusText = String(parsed.statusText || (installed ? "Sync paused" : "Not installed")) + syncDir = String(parsed.syncDir || "") + syncMode = String(parsed.syncMode || "Two-way") + clientVersion = String(parsed.clientVersion || "") + resumeAt = Number(parsed.resumeAt || 0) + lastSyncTs = Number(parsed.lastSyncTs || 0) + usedBytes = Number(parsed.usedBytes || 0) + quotaBytes = Number(parsed.quotaBytes || 0) + quotaKnown = parsed.quotaKnown === true + quotaCheckedTs = Number(parsed.quotaCheckedTs || 0) + quotaError = String(parsed.quotaError || "") + remoteStatus = String(parsed.remoteStatus || "Not checked") + syncStatusCheckedTs = Number(parsed.syncStatusCheckedTs || 0) + syncStatusError = String(parsed.syncStatusError || "") + remoteCheckedTs = Number(parsed.remoteCheckedTs || 0) + remoteError = String(parsed.remoteError || "") + files = parsed.files || [] + activity = parsed.activity || [] + lastError = String(parsed.lastError || "") + // Last, and only once the whole snapshot is applied: this is the gate that + // lets an account contribute to the aggregate, and opening it early would + // publish default values as if they were a reading. + initialized = true + accountStateChanged() + + if (resyncRequired && !wasResync) + report("resync", "OneDrive needs a resync", "Resync required", + "Syncing stopped until the resync repair runs.", "repair") + else if (serviceFailed && !wasFailed) + report("failed", "OneDrive sync failed", "Sync failed", + lastError !== "" ? lastError : "The OneDrive service entered a failed state.", "open") + if (reauthRequired && !wasReauth) + report("reauth", "OneDrive needs reauthentication", "Reauthentication required", + "Sign in again to keep syncing.", "open") + // Recovery is only meaningful for an account that was seen unhealthy first. + if (hadAttention && !serviceFailed && !resyncRequired && !reauthRequired) + report("recovered", "OneDrive recovered", "Recovered", "Syncing is healthy again.") + if (!wasStorageSevere && Model.usageSevere(usedBytes, quotaBytes, quotaKnown)) + report("storage", "OneDrive storage almost full", "Almost full", + Model.freeText(usedBytes, quotaBytes, quotaKnown) + " of " + + Model.formatBytes(quotaBytes) + " remains.") + } + + // Reached when the status process stopped without an exit -- it could not be + // started, or the watchdog gave up on it. Everything onExited would have + // released has to be released here too, or the account keeps the coordinator's + // poll slot and no other account is ever polled again. + function abandonStatus(run, reason) { + // Not this invocation any more: the process was restarted before this + // deferred callback ran, and settling now would abandon a live poll. + if (run !== _statusRun || _statusSettledRun === run) return + _statusSettledRun = run + statusWatchdog.stop() + refreshing = false + if (_pendingGeneration === generation) { + attempted = true + lastError = reason + } + _activeCloudMode = "" + _cloudRequested = "" + _quotaRetryQueued = false + accountStateChanged() + pollFinished(root.service) + } + + // In Model.js so it is testable; kept as a method because four handlers and + // two bindings call it. + function elideStatus(text) { return Model.elideStatus(text) } + + function login() { + if (!installed) return + Quickshell.execDetached(Commands.login(confdir)) + actionStatus = "Opened OneDrive login" + actionStatusTimer.restart() + } + + function reauthenticate() { + if (!installed || running) return + Quickshell.execDetached(Commands.login(confdir, "reauth")) + actionStatus = "Opened OneDrive reauthentication" + actionStatusTimer.restart() + } + + function repairResync() { + if (!installed || running || busy) return + Quickshell.execDetached(Commands.login(confdir, "resync")) + actionStatus = "Opened OneDrive resync repair" + actionStatusTimer.restart() + } + + function openWeb() { + Quickshell.execDetached(["uwsm-app", "--", "xdg-open", "https://onedrive.live.com/"]) + } + + function pause() { + if (busy) return + _pauseMinutes = 0 + cancelResumeTimer("pause") + } + + function pauseFor(minutes) { + var requested = parseInt(String(minutes), 10) + if (!isFinite(requested) || requested <= 0) return + var duration = Math.max(5, Math.min(1440, requested)) + if (!installed || !serviceAvailable || !authenticated || busy + || serviceFailed || resyncRequired || reauthRequired) return + // No derivable resume unit means no timer can be scheduled for this account. + // An untimed pause is honest; a timer that collides with another account is + // not. Say so, or the user gets an indefinite pause from a button labelled + // "4 hours". + if (resumeUnit === "") { + actionStatus = "Paused — no resume timer is available for this account" + actionStatusTimer.restart() + pause() + return + } + _pauseMinutes = duration + cancelResumeTimer("pause") + } + + function resume() { + if (!authenticated) { + login() + return + } + if (busy) return + _pauseMinutes = 0 + cancelResumeTimer("resume") + } + + function toggleRunning() { + if (active) pause() + else resume() + } + + function runControl(command, desired) { + if (!installed || !serviceAvailable || controlProcess.running) return + _desired = desired + _controlDesired = desired + _controlOutput = "" + _controlError = "" + controlProcess.command = command + _controlRun += 1 + controlWatchdog.restart() + controlProcess.running = true + } + + function cancelResumeTimer(afterAction) { + if (resumeUnit === "") { + // Nothing to cancel, and "systemctl stop .timer .service" is not a command + // worth sending. Continue straight to the action the cancel precedes. + _afterTimerCancel = afterAction + Qt.callLater(function() { root.afterResumeTimerCancelled() }) + return + } + _afterTimerCancel = afterAction + _timerOutput = "" + _timerError = "" + cancelTimerProcess.command = Commands.cancelResume(resumeUnit) + _cancelRun += 1 + cancelWatchdog.restart() + cancelTimerProcess.running = true + } + + function settleResumeTimerCancel(run) { + if (run !== _cancelRun || _cancelSettledRun === run) return + _cancelSettledRun = run + cancelWatchdog.stop() + afterResumeTimerCancelled() + } + + // Shared by the cancel process and by the no-timer path, which has nothing to + // cancel but must still perform the action the cancel precedes. + function afterResumeTimerCancelled() { + var action = _afterTimerCancel + _afterTimerCancel = "" + resumeAt = 0 + if (action === "resume") { + runControl(Commands.control("start", root.service), 1) + } else if (action === "pause") { + if (running || active || activeState === "activating") { + runControl(Commands.control("stop", root.service), 0) + } else if (_pauseMinutes > 0) { + var minutes = _pauseMinutes + _pauseMinutes = 0 + scheduleResume(minutes) + } else { + requestRefresh() + } + } + } + + function scheduleResume(minutes) { + _timerOutput = "" + _timerError = "" + scheduleTimerProcess.command = Commands.scheduleResume(resumeUnit, service, minutes) + _scheduleRun += 1 + scheduleWatchdog.restart() + scheduleTimerProcess.running = true + } + + function openFolder() { + if (syncDir !== "") Quickshell.execDetached(["uwsm-app", "--", "xdg-open", syncDir]) + } + + function openFile(file) { + if (!file || !file.path) return + Quickshell.execDetached(["uwsm-app", "--", "nautilus", "--select", fileUri(String(file.path))]) + } + + function fileUri(path) { + var parts = String(path || "").split("/") + for (var index = 0; index < parts.length; index++) parts[index] = encodeURIComponent(parts[index]) + return "file://" + parts.join("/") + } + + // No repeating poll timer here: the coordinator owns cadence, so N accounts + // share one budget instead of each polling every refreshIntervalSec. The + // timers that remain are per-account control flow -- settling after a control + // command, and clearing transient action text. + + Timer { + id: delayedRefresh + interval: 750 + repeat: false + onTriggered: root.requestRefresh() + } + + // After a control succeeds, keep asking until a poll taken AFTER it lands. + // + // This used to give up after five ticks and clear the optimistic state + // unconditionally. `requestRefresh` is drop-not-queue, so with two or three + // accounts a neighbour holding the shared poll slot swallowed every one of + // those five asks -- and six seconds after the user paused Work, the bar + // reverted to "Monitoring" from a sample that predated the pause, while the + // unit was really stopped. Reverting to known-stale data is strictly worse + // than holding the user's intent, so the intent is now held until fresh truth + // arrives (`applyStatus` clears it the moment reality agrees) or the account + // gives up asking entirely. + Timer { + id: settleTimer + property int ticks: 0 + property int baseline: 0 + // Whether this account is waiting to be told what a control actually did. + // The coordinator gives it the next poll slot, so the answer arrives in one + // round rather than never. + readonly property bool waiting: running && root._confirmedPoll <= baseline + interval: root.settleIntervalMs + repeat: true + onTriggered: { + ticks += 1 + root.requestRefresh() + if (root._confirmedPoll > baseline) { + // A poll taken after the control has landed and applyStatus has had its + // say. Anything still optimistic now is a genuine divergence. + ticks = 0 + stop() + root._desired = -1 + } else if (ticks >= 30) { + // ~36s of a completely blocked slot. Stop asking, but leave the intent + // alone: the next successful poll clears it through applyStatus. + ticks = 0 + stop() + } + } + } + + // A helper that never exits -- a wedged python3, an os.walk over a stalled + // network mount -- held the single poll slot forever, and with it every other + // account's polling. Nothing else in the stack bounds this: the helper's own + // 30s limit covers only its outbound CLI calls, not its local directory scan. + // The control processes need the same bound the poll has. `pause()` refuses + // while `busy`, and `busy` is true for as long as one of these runs -- so a + // `systemctl --user stop` that never exits (the user bus not yet back after a + // suspend is the realistic way) left that account's Pause and Resume dead for + // the rest of the session, recoverable only by restarting the bar. + Timer { + id: controlWatchdog + interval: root.statusTimeoutMs + repeat: false + onTriggered: { + if (root._controlSettledRun === root._controlRun) return + var run = root._controlRun + controlProcess.running = false + root.settleControl(run, 124) + } + } + + Timer { + id: cancelWatchdog + interval: root.statusTimeoutMs + repeat: false + onTriggered: { + if (root._cancelSettledRun === root._cancelRun) return + var run = root._cancelRun + cancelTimerProcess.running = false + // The action the cancel precedes still goes ahead: an untimed pause is a + // worse outcome than a stranded timer, but silently doing nothing at all + // is worse than both. + root.settleResumeTimerCancel(run) + } + } + + Timer { + id: scheduleWatchdog + interval: root.statusTimeoutMs + repeat: false + onTriggered: { + if (root._scheduleSettledRun === root._scheduleRun) return + var run = root._scheduleRun + scheduleTimerProcess.running = false + root.settleResumeSchedule(run, 124) + } + } + + Timer { + id: statusWatchdog + interval: root.statusTimeoutMs + repeat: false + onTriggered: { + if (root._statusSettled) return + // Assigning false terminates it in real Quickshell; the exit that follows + // finds the process already settled and is ignored. + var run = root._statusRun + statusProcess.running = false + root.abandonStatus(run, "OneDrive status check timed out") + } + } + + Timer { + id: actionStatusTimer + interval: 2500 + repeat: false + onTriggered: root.actionStatus = "" + } + + Process { + id: statusProcess + running: false + command: [] + stdout: StdioCollector { + id: statusStdout + waitForEnd: true + onStreamFinished: root._statusOutput = text + } + stderr: StdioCollector { + id: statusStderr + waitForEnd: true + onStreamFinished: root._statusError = text + } + onRunningChanged: { + // Ordering between `exited` and `running` is not ours to rely on, so defer: + // by the time this runs, a real exit has already settled the process and + // this is a no-op. Only a failure to start reaches abandonStatus. + if (!running) { + var run = root._statusRun + Qt.callLater(function() { + root.abandonStatus(run, "Could not run the OneDrive status helper") + }) + } + } + onExited: function(exitCode) { + if (root._statusSettledRun === root._statusRun) return + root._statusSettledRun = root._statusRun + statusWatchdog.stop() + var cloudMode = root._activeCloudMode + root.refreshing = false + // Only a reply for the CURRENT identity counts as an attempt; a discarded + // one would tell the ramp this account had been sampled when it has not. + if (root._pendingGeneration === root.generation) root.attempted = true + if (root._pendingGeneration !== root.generation) { + // Started under a previous config directory. Applying it would restore + // that directory's syncDir, quota and edge latches over the new + // account's. Everything the normal path clears must still be cleared, or + // a pending cloud request keeps holding the global semaphore until the + // next routine poll. + root._activeCloudMode = "" + root._cloudRequested = "" + root._quotaRetryQueued = false + root.pollFinished(root.service) + return + } + var stdout = String(statusStdout.text || root._statusOutput || "") + var stderr = String(statusStderr.text || root._statusError || "") + if (exitCode === 0) root.applyStatus(stdout) + else root.lastError = root.elideStatus(stderr || stdout || "Could not read OneDrive status") + if (cloudMode !== "") { + if (exitCode !== 0) root.actionStatus = root.lastError + else if (cloudMode === "quota") + root.actionStatus = root.quotaError === "" ? "Storage refreshed" : root.quotaError + else root.actionStatus = root.syncStatusError === "" + ? "Sync verified" : root.syncStatusError + actionStatusTimer.restart() + } + root._activeCloudMode = "" + if (root._cloudRequested !== "") { + var requested = root._cloudRequested + root._cloudRequested = "" + // Back through the coordinator, not straight into startCloudCheck: + // going direct released the shared slot and then took it again without + // asking, which let a second account start its own check in between. + Qt.callLater(function() { root.requestCloud(requested) }) + } + if (root._quotaRetryQueued) { + root._quotaRetryQueued = false + Qt.callLater(function() { root.maybeRetryStaleQuota() }) + } + root.pollFinished(root.service) + } + } + + Process { + id: cancelTimerProcess + running: false + command: [] + stdout: StdioCollector { waitForEnd: true } + stderr: StdioCollector { waitForEnd: true } + // A cancel that cannot even start must still let the action it precedes + // through, or pause and resume simply do nothing from then on. + onRunningChanged: { + if (!running) { + var run = root._cancelRun + Qt.callLater(function() { root.settleResumeTimerCancel(run) }) + } + } + onExited: function(exitCode) { root.settleResumeTimerCancel(root._cancelRun) } + } + + function settleResumeSchedule(run, exitCode) { + if (run !== _scheduleRun || _scheduleSettledRun === run) return + _scheduleSettledRun = run + scheduleWatchdog.stop() + var stdout = String(timerStdout.text || _timerOutput || "") + var stderr = String(timerStderr.text || _timerError || "") + if (exitCode !== 0) { + lastError = elideStatus(stderr || stdout || "Could not schedule OneDrive resume") + actionStatus = "Timed pause failed; resuming syncing…" + _scheduleRecovery = true + // Recovery starts the SAME service the pause stopped. + runControl(Commands.control("start", root.service), 1) + } else { + lastError = "" + actionStatus = "Timed pause scheduled" + actionStatusTimer.restart() + requestRefresh() + } + } + + Process { + id: scheduleTimerProcess + running: false + command: [] + stdout: StdioCollector { + id: timerStdout + waitForEnd: true + onStreamFinished: root._timerOutput = text + } + stderr: StdioCollector { + id: timerStderr + waitForEnd: true + onStreamFinished: root._timerError = text + } + // systemd-run failing to START is the same outcome for the user as it + // failing: the account is already stopped and nothing will resume it. Take + // the recovery path rather than leaving it paused indefinitely. + onRunningChanged: { + if (!running) { + var run = root._scheduleRun + Qt.callLater(function() { root.settleResumeSchedule(run, 127) }) + } + } + onExited: function(exitCode) { root.settleResumeSchedule(root._scheduleRun, exitCode) } + } + + function settleControl(run, exitCode) { + if (run !== _controlRun || _controlSettledRun === run) return + _controlSettledRun = run + controlWatchdog.stop() + var desired = root._controlDesired + root._controlDesired = -1 + var stdout = String(controlStdout.text || root._controlOutput || "") + var stderr = String(controlStderr.text || root._controlError || "") + if (exitCode !== 0) { + root._desired = -1 + root._pauseMinutes = 0 + root._scheduleRecovery = false + root.lastError = root.elideStatus(stderr || stdout || "OneDrive service command failed") + } else { + root.lastError = "" + settleTimer.ticks = 0 + // Every poll started from here on outranks this number; one started + // before the control does not, however late it comes back. + settleTimer.baseline = root._pollsStarted + settleTimer.start() + if (desired === 0 && root._pauseMinutes > 0) { + var minutes = root._pauseMinutes + root._pauseMinutes = 0 + root.scheduleResume(minutes) + } else if (root._scheduleRecovery) { + root._scheduleRecovery = false + root.actionStatus = "Timed pause failed; syncing resumed" + actionStatusTimer.restart() + } + } + delayedRefresh.restart() + } + + Process { + id: controlProcess + running: false + command: [] + stdout: StdioCollector { + id: controlStdout + waitForEnd: true + onStreamFinished: root._controlOutput = text + } + stderr: StdioCollector { + id: controlStderr + waitForEnd: true + onStreamFinished: root._controlError = text + } + // A control that could not start leaves the unit exactly as it was, so the + // optimistic state has to be dropped -- otherwise the bar claims a pause + // that never happened. + onRunningChanged: { + if (!running) { + var run = root._controlRun + Qt.callLater(function() { root.settleControl(run, 127) }) + } + } + onExited: function(exitCode) { root.settleControl(root._controlRun, exitCode) } + } +} diff --git a/BarWidget.qml b/BarWidget.qml index 92cef5f..0b2f2a9 100644 --- a/BarWidget.qml +++ b/BarWidget.qml @@ -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 @@ -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 openAccount ` 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 { @@ -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() diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b91456..0ab6205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 1.6.0 - 2026-09-02 + +- Discover every configured OneDrive account -- the plain service and any + `onedrive@` 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 diff --git a/Commands.js b/Commands.js new file mode 100644 index 0000000..0296a11 --- /dev/null +++ b/Commands.js @@ -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 `, +// 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 + } +} diff --git a/Model.js b/Model.js index 3fa5673..0012e8a 100644 --- a/Model.js +++ b/Model.js @@ -14,32 +14,6 @@ var DOCUMENT_EXTENSIONS = { pages: true, numbers: true, key: true } -var IPC_TARGET = "io.github.salemsayed.omaonedrive" - -// Actions passed to omarchy-notification-send --exec are deliberately a closed -// argv set. Omarchy 4.0.1 consumes every argument after --exec as the click -// command, so the delimiter must follow the notification text and each command -// word must remain a separate array element. -function notificationActionArgv(behavior) { - if (behavior === "open") return ["omarchy-shell", IPC_TARGET, "open"] - if (behavior === "repair") return ["omarchy-shell", IPC_TARGET, "resync"] - return [] -} - -function notificationCommand(urgency, summary, body, behavior) { - var command = [ - "omarchy-notification-send", "--app-name", "OmaOneDrive", "--urgency", urgency, - summary, body - ] - var actionArgv = notificationActionArgv(behavior) - if (actionArgv.length > 0) { - command.push("--exec") - for (var index = 0; index < actionArgv.length; index++) - command.push(actionArgv[index]) - } - return command -} - function defaultStatus() { return { ok: true, @@ -256,7 +230,7 @@ function folderName(path) { // local filename from the OneDrive journal remains visible but cannot become // rich text. function inheritedPlainText(value) { - return String(value || "").replace(//g, "›") + return String(value || "").replace(//g, "\u203a") } function tooltip(status, nowMs) { @@ -277,6 +251,597 @@ function heroMeta(status) { return inheritedPlainText(parts.join(" · ")) } +// --- multi-account aggregation ------------------------------------------------ + +// "personal" -> "Personal", "work-mail" -> "Work Mail". The plain service has no +// instance and is simply "OneDrive", which is also the hero title, so a +// single-account install reads exactly as it does today. The description is +// accepted for callers that want it but is not used: systemd descriptions are +// sentences ("OneDrive sync (personal account)"), not labels for a tab. +function accountName(instance, description) { + var value = String(instance || "").trim() + if (value === "") return "OneDrive" + var words = value.replace(/[_-]+/g, " ").split(" ") + var named = [] + for (var index = 0; index < words.length; index++) { + var word = words[index] + if (word === "") continue + named.push(word.charAt(0).toUpperCase() + word.slice(1)) + } + return named.length ? named.join(" ") : value +} + +// One total order, worst first. Ranked rather than named-compared so the bar can +// pick a winner without knowing what any particular state means. +// +// resync is deliberately checked before failed: a required resync exits 126, +// which sets serviceFailed too, and "Resync required" is the actionable half. +var ACCOUNT_STATES = [ + { kind: "resync", rank: 1 }, + { kind: "reauth", rank: 2 }, + { kind: "failed", rank: 3 }, + { kind: "missing", rank: 4 }, + { kind: "login", rank: 5 }, + { kind: "unavailable", rank: 6 }, + { kind: "paused", rank: 7 }, + { kind: "starting", rank: 8 }, + { kind: "syncing", rank: 9 }, + { kind: "healthy", rank: 10 } +] + +function accountStateKind(account) { + if (!account || typeof account !== "object") return "missing" + if (account.resyncRequired === true) return "resync" + if (account.reauthRequired === true) return "reauth" + if (account.serviceFailed === true) return "failed" + if (account.installed !== true) return "missing" + if (account.authenticated !== true) return "login" + if (account.serviceAvailable !== true) return "unavailable" + if (String(account.activeState || "") === "activating") return "starting" + // `active` folds in the optimistic desired state, which is what made the old + // bar respond to Pause and Resume immediately instead of a poll later. Fall + // back to `running` for plain objects that have no `active`. + var isActive = account.active !== undefined ? account.active === true : account.running === true + if (!isActive) return "paused" + // ...and a syncing flag left over from before a pause must not outrank it. + if (account.syncing === true) return "syncing" + return "healthy" +} + +function accountState(account) { + var kind = accountStateKind(account) + for (var index = 0; index < ACCOUNT_STATES.length; index++) { + if (ACCOUNT_STATES[index].kind === kind) return ACCOUNT_STATES[index] + } + return ACCOUNT_STATES[ACCOUNT_STATES.length - 1] +} + +// Worst of N. Until every discovered account has produced a first sample the +// aggregate is "checking" with no badge, so default property values cannot flash +// a missing-client badge before the first poll lands. +function aggregateAccounts(accounts) { + var list = Array.isArray(accounts) ? accounts : [] + if (list.length === 0) { + return { kind: "checking", rank: 0, count: 0, worst: null, anyActive: false, initialized: false } + } + // Accounts that have not reported yet are EXCLUDED rather than gating the + // whole aggregate. Excluding them already prevents default property values + // from flashing a missing-client badge, which is the reason the design gives + // for the checking state -- while gating on all of them meant a single + // permanently-failing account (its confdir unreadable, say) froze the bar at + // "checking" forever, hiding a resync-required account behind it. + var anyInitialized = false + var worst = null + var worstRank = Number.MAX_VALUE + var anyActive = false + for (var index = 0; index < list.length; index++) { + var account = list[index] + if (!account || account.initialized !== true) continue + anyInitialized = true + var state = accountState(account) + // Strictly less-than, so equal ranks keep discovery order. + if (state.rank < worstRank) { + worstRank = state.rank + worst = account + } + // account.active already folds in the optimistic _desired state, so a just- + // pressed Pause dims the icon immediately instead of waiting for a poll. + // Fall back to the raw fields for plain objects that have no `active`. + var stated = account.active + var isActive = stated !== undefined + ? stated === true + : (account.running === true || String(account.activeState || "") === "activating") + // A transfer keeps the icon lit even between "running" samples -- but NOT + // for an account the user has just paused. The helper only ever sets + // `syncing` alongside `running`, so the leftover flag from the sample before + // the pause was the one thing this clause could still light, and the icon + // stayed bright until a confirming poll landed. Pausing during an upload + // looked like it had not taken. + if (isActive || (account.syncing === true && stated !== false)) anyActive = true + } + if (!anyInitialized || worst === null) { + return { + kind: "checking", rank: 0, count: list.length, + worst: null, anyActive: anyActive, initialized: false + } + } + var kind = accountStateKind(worst) + return { + // Worst-first, INCLUDING a pause. A reviewer argued that progress should + // outrank a deliberate pause on the badge, and for one round it did; the + // user overruled it: a paused account anywhere is a state you must be shown, + // and every account has to be working before the bar looks normal. The + // badge, the tooltip and the aggregate therefore all answer with the same + // worst account. + kind: kind, + rank: worstRank, + count: list.length, + worst: worst, + anyActive: anyActive, + initialized: true + } +} + +// N=1 keeps exactly today's one-line tooltip. N>1 is attributed and ordered +// worst first, then discovery order, capped so a large installation cannot grow +// an unbounded tooltip. +function aggregateTooltip(accounts, nowMs, maxLines) { + var list = Array.isArray(accounts) ? accounts : [] + if (list.length === 0) return "Checking OneDrive…" + if (list.length === 1) { + var only = list[0] + // An account that has not reported still carries its default values, and the + // default `installed: false` renders as "OneDrive CLI is not installed" -- + // which sends the user looking for a missing package when what actually + // happened is that the status helper could not be run, or timed out. Say + // that instead, and only guess at the client when a poll has told us. + if (!only || only.initialized !== true) { + var why = only && only.attempted === true ? String(only.lastError || "") : "" + if (why === "") return "Checking OneDrive…" + return inheritedPlainText("OneDrive status unavailable\n" + why) + } + return tooltip(only, nowMs) + } + // An account that has been polled and still cannot report is not "checking": + // its helper failed, or timed out, and it will keep failing. Saying so is the + // only way the user learns which account is broken and why -- and when the + // helper is missing outright, EVERY account is in this state at once, so this + // is the whole tooltip, not a footnote. + var broken = list.filter(function (account) { + return account && account.initialized !== true && account.attempted === true + && String(account.lastError || "") !== "" + }) + var summary = aggregateAccounts(list) + if (!summary.initialized) { + if (broken.length === 0) return "Checking " + list.length + " OneDrive accounts…" + var stalled = ["OneDrive status unavailable"] + for (var b = 0; b < broken.length; b++) { + stalled.push(accountName(broken[b].instance, broken[b].description) + + ": " + String(broken[b].lastError)) + } + if (broken.length < list.length) { + stalled.push("Checking " + (list.length - broken.length) + " more…") + } + return inheritedPlainText(stalled.join("\n")) + } + + // Only accounts that have reported. An un-polled account still carries default + // values, which classify as "missing" and would sort to the TOP of a + // worst-first list -- so the tooltip would announce a missing client while the + // badge, which already excludes them, showed nothing. + var reported = list.filter(function (account) { + return account && account.initialized === true + }) + var ordered = reported.map(function (account, index) { + return { account: account, index: index, rank: accountState(account).rank } + }) + ordered.sort(function (left, right) { + return left.rank === right.rank ? left.index - right.index : left.rank - right.rank + }) + + var cap = maxLines === undefined ? 5 : maxLines + var lines = ["OneDrive · " + list.length + " accounts"] + // A broken account listed among the healthy ones, so a permanently failing + // helper is visible next to the accounts that ARE working rather than being + // folded into a "checking" count that never goes down. + for (var f = 0; f < broken.length; f++) { + lines.push(accountName(broken[f].instance, broken[f].description) + + ": " + String(broken[f].lastError)) + } + var stillChecking = list.length - reported.length - broken.length + if (stillChecking > 0) lines.push("Checking " + stillChecking + " more…") + for (var index = 0; index < ordered.length && index < cap; index++) { + var account = ordered[index].account + lines.push(accountName(account.instance, account.description) + ": " + tooltip(account, nowMs)) + } + if (ordered.length > cap) lines.push("+" + (ordered.length - cap) + " more") + // The per-account lines are already plain (tooltip() wraps itself), but the + // account NAMES and the broken-account error lines are helper-derived too. + return inheritedPlainText(lines.join("\n")) +} + +// Map an aggregate state onto the bar's existing badge vocabulary. The visual +// language does not grow with the state list: several states share a badge and +// the tooltip distinguishes them, because at eight pixels a badge can only +// carry "something is wrong", "signed out", "paused" or "working". +// What the bar icon does with an aggregate: lit or dim, spinning or not. +// +// This lived as three expressions in BarWidget.qml, which derives from the bar's +// own BarWidget type and so cannot be instantiated by any harness. A reviewer +// demonstrated the cost: the old broken `installed` expression -- the one that +// dimmed a bar with a healthy account syncing because some OTHER account was +// missing -- could be restored there with the entire suite still green. +function barState(aggregate) { + var summary = aggregate && typeof aggregate === "object" ? aggregate : {} + var kind = String(summary.kind || "") + // Lit while ANY account is working, so one paused account does not dim a bar + // that is still syncing two others. + var anyActive = summary.anyActive === true + return { + active: anyActive, + syncing: kind === "syncing" || kind === "starting", + // Dimming asks whether ANYTHING is usable, which is not the question the + // badge answers. Deriving it from the badge kind left the icon undimmed + // before the first poll, and undimmed while showing the missing-client badge + // for an account whose unit is merely unavailable. + installed: summary.initialized === true + && (anyActive || (kind !== "missing" && kind !== "unavailable")) + } +} + +// One line of error text, fit for a tooltip or a status row. +// +// A systemd or onedrive error can be several hundred characters of multi-line +// output; pasting that straight into the panel pushed every other row off the +// screen. This lived in Account.qml, where no test could reach it. +function elideStatus(text) { + var value = String(text || "").replace(/\s+/g, " ").trim() + return value.length > 180 ? value.substring(0, 177) + "…" : value +} + +function badgeKind(kind) { + if (kind === "resync" || kind === "reauth" || kind === "failed") return "attention" + if (kind === "missing" || kind === "unavailable") return "missing" + if (kind === "login") return "login" + if (kind === "paused") return "paused" + if (kind === "starting" || kind === "syncing") return "syncing" + return "" // healthy, and checking before the first poll +} + +// Which account the panel should show when it is opened from the bar. +// +// The badge is worst-of-N, but every control in the panel -- the buttons, the +// keyboard shortcuts, right-click Storage, middle-click Folder, and the IPC +// controls -- acts on the SELECTED account, which stays wherever the user last +// left it (the first discovered account, until they pick a tab). So the bar +// could show "reauthentication required" for Work while the panel opened on a +// perfectly healthy Personal, and pressing P paused Personal. +// +// Returns the service to select, or "" to leave the selection alone. The user's +// own choice is only overridden when it is not itself asking for attention: +// having deliberately opened Work to deal with it, they must not be bounced to +// Personal the moment Personal goes wrong too. +function openSelection(summary, selectedKind) { + var aggregate = summary && typeof summary === "object" ? summary : {} + var kind = String(aggregate.kind || "") + if (needsAttention(kind)) { + // A selection the user made for a reason is not stolen: having opened Work + // to deal with its reauth, they must not be bounced to Personal the moment + // Personal fails too. + if (needsAttention(selectedKind)) return "" + return aggregate.worst ? String(aggregate.worst.service || "") : "" + } + // A spinning bar is about the transfer. Under worst-first the transferring + // account IS the worst one, so clicking the spin reaches it rather than + // whatever was selected last -- but never away from an account the user is + // already watching sync. + if ((kind === "syncing" || kind === "starting") && selectedKind !== "syncing" + && selectedKind !== "starting") { + return aggregate.worst ? String(aggregate.worst.service || "") : "" + } + return "" +} + +// The states that put a badge on the bar and have something for the user to do. +// "paused" is deliberate and "syncing" is progress, so neither steals a +// selection. +function needsAttention(kind) { + return kind === "resync" || kind === "reauth" || kind === "failed" + || kind === "missing" || kind === "login" || kind === "unavailable" +} + +// Compose one desktop notification from the events of a single polling burst. +// +// Three accounts going wrong at once must not produce three popups, and the old +// unattributed summaries ("OneDrive sync failed") are useless when several +// accounts exist. Returns null when there is nothing to send. +// +// An event is { service, name, kind, body, action } where kind is one of +// "resync" | "failed" | "reauth" | "storage" | "recovered". +// Must agree with ACCOUNT_STATES, or a grouped notification opens a different +// account than the bar badge blames. +var ATTENTION_KINDS = { resync: 1, reauth: 2, failed: 3 } + +function composeNotification(events, multiAccount) { + var list = Array.isArray(events) ? events.filter(function (event) { + return event && typeof event === "object" + }) : [] + if (list.length === 0) return null + + var attention = list.filter(function (event) { return ATTENTION_KINDS[event.kind] }) + var recovered = list.filter(function (event) { return event.kind === "recovered" }) + var storage = list.filter(function (event) { return event.kind === "storage" }) + + // Attention outranks everything: it is the only kind that is actionable. + if (attention.length === 1 && recovered.length === 0 && storage.length === 0) { + var only = attention[0] + return { + urgency: "critical", + summary: multiAccount ? only.summary + " — " + only.name : only.summary, + body: only.body, + action: only.action || "", + service: only.service + } + } + if (attention.length > 0) { + var worst = attention.slice().sort(function (left, right) { + return ATTENTION_KINDS[left.kind] - ATTENTION_KINDS[right.kind] + })[0] + var others = attention.concat(recovered, storage) + // With one account every event is about that account, so "needs attention in + // 2 accounts" would be nonsense. Lead with the worst condition and keep its + // own action -- which is how a single account behaved before this work. + if (!multiAccount) { + return { + urgency: "critical", + summary: worst.summary, + body: others.length > 1 + ? others.map(function (event) { return event.short }).join("\n") + : worst.body, + action: worst.action || "open", + service: worst.service + } + } + if (attention.length === 1) { + return { + urgency: "critical", + summary: worst.summary + " — " + worst.name, + body: others.map(function (event) { return event.name + ": " + event.short }).join("\n"), + // A grouped popup deliberately does not carry a direct repair action: it + // cannot know which account the reader meant. + action: "open", + service: worst.service + } + } + // Count ACCOUNTS, not events: one account with two conditions is not two + // accounts. And carry the other kinds in the body -- a storage threshold is + // edge-latched, so dropping it here loses it until it clears and re-arms. + var names = {} + for (var scan = 0; scan < attention.length; scan++) names[attention[scan].service] = true + var accountCount = Object.keys(names).length + return { + urgency: "critical", + summary: accountCount > 1 + ? "OneDrive needs attention in " + accountCount + " accounts" + : worst.summary + " — " + worst.name, + body: others.map(function (event) { return event.name + ": " + event.short }).join("\n"), + action: "open", + service: worst.service + } + } + if (storage.length > 0) { + // A recovery in the same burst is reported in the body rather than dropped; + // the previous code returned the storage popup alone. + if (storage.length === 1 && recovered.length === 0) { + return { + urgency: "normal", + summary: multiAccount ? storage[0].summary + " — " + storage[0].name : storage[0].summary, + body: storage[0].body, + action: "", + service: storage[0].service + } + } + if (storage.length === 1) { + return { + urgency: "normal", + summary: multiAccount ? storage[0].summary + " — " + storage[0].name : storage[0].summary, + body: storage.concat(recovered).map(function (event) { + return multiAccount ? event.name + ": " + event.short : event.short + }).join("\n"), + action: "", + service: storage[0].service + } + } + return { + urgency: "normal", + summary: storage.length + " OneDrive accounts are almost full", + // Recoveries are carried here too. The single-storage branch above already + // does this; omitting it here dropped an edge-latched recovery, which then + // never re-reports. + body: storage.concat(recovered).map(function (event) { + return event.name + ": " + event.short + }).join("\n"), + action: "", + service: storage[0].service + } + } + if (recovered.length === 1) { + return { + urgency: "normal", + summary: multiAccount ? "OneDrive recovered — " + recovered[0].name : "OneDrive recovered", + body: recovered[0].body, + action: "", + service: recovered[0].service + } + } + if (recovered.length > 1) { + return { + urgency: "normal", + summary: recovered.length + " OneDrive accounts recovered", + body: recovered.map(function (event) { return event.name + ": " + event.short }).join("\n"), + action: "", + service: recovered[0].service + } + } + // No branch matched: an event kind this function does not know about. Say + // nothing rather than throwing -- flushTransitions has already cleared the + // pending list, so an exception here would lose the whole burst silently. + return null +} + +// The glyph for a badge kind. Shared so the bar badge and the account selector +// cannot drift apart -- they are the same vocabulary at two sizes. +function badgeGlyph(kind) { + if (kind === "missing") return "\u{f0156}" + if (kind === "login") return "\u{f030b}" + if (kind === "paused") return "\u{f03e4}" + if (kind === "syncing") return "\u{f0453}" + if (kind === "attention") return "\u{f002a}" + return "" +} + +// --- scheduling decisions ----------------------------------------------------- +// +// These are pure so they can be tested. The QML that calls them cannot be, and a +// reviewer's observation is the reason they exist here: "the suite is green; +// that is not evidence these paths work". Deleting the ramp or bypassing the +// semaphore used to pass every test. + +// Which account should take the next poll slot? Returns an index, or -1. +// `accounts` is [{ routinePolling, initialized }]. Accounts that have not +// reported yet take priority, but the cursor still advances, so several +// unreported accounts interleave rather than the first one taking every slot. +function nextPollIndex(accounts, cursor) { + var list = Array.isArray(accounts) ? accounts : [] + if (list.length === 0) return -1 + var start = ((cursor % list.length) + list.length) % list.length + // Three passes: an account waiting on the result of a control it just ran, + // then one that has never been attempted, then everyone else. + for (var pass = 0; pass < 3; pass++) { + for (var step = 0; step < list.length; step++) { + var index = (start + step) % list.length + var account = list[index] + // `busy` covers a cloud check too: such an account will refuse the slot, + // so handing it one wastes the tick entirely. + if (!account || account.routinePolling === true || account.busy === true) continue + // Priority is "has not been ATTEMPTED yet", not "has not reported". An + // account whose helper always fails never reports, and gating on that gave + // it every slot forever while the healthy accounts went unpolled. + // An account that has just paused or resumed is holding an optimistic + // state that only a fresh poll can confirm or drop. Until it gets one the + // bar shows the pre-control sample, so it goes first -- ahead even of the + // startup ramp, which is at worst a few seconds of "checking". + if (pass === 0 && account.settling !== true) continue + if (pass === 1 && account.attempted === true) continue + return index + } + } + return -1 +} + +// May this cloud request start now, and if not, should it be queued? +// Returns "start" | "queue" | "drop". +function cloudDecision(busy, queue, service, mode, active) { + if (!service || !mode) return "drop" + if (busy) { + // The request already RUNNING counts as a duplicate too. Seeing only a + // boolean, this used to queue a second copy of the check in flight, which + // then ran the same 30-second query again the moment the first finished. + if (active && active.service === service && active.mode === mode) return "drop" + var pending = Array.isArray(queue) ? queue : [] + for (var index = 0; index < pending.length; index++) { + if (pending[index].service === service && pending[index].mode === mode) return "drop" + } + return "queue" + } + return "start" +} + +// Decide how to bring the current descriptor list in line with what discovery +// returned, as a plan of operations rather than a rebuilt list. Delegates must +// be preserved for services that still exist: recreating them would drop +// in-flight processes and the notification edge history that decides whether a +// condition is newly true. +// +// `current` is the ordered list of service names already present. +// Returns { updates: [{index,row}], appends: [row], removes: [index desc] }. +// --- the IPC account surface ------------------------------------------------- +// +// These two live here rather than inline in BarWidget.qml because that file +// cannot be instantiated headless -- it derives from the bar's own BarWidget +// type -- so logic left inside it is unreachable by any test. Both functions +// were previously deletable outright without a single assertion failing. + +// One row per account, in discovery order, for `omarchy-cmd ... accounts`. +function accountRows(accounts, selectedService) { + var list = accounts || [] + var rows = [] + for (var index = 0; index < list.length; index++) { + var account = list[index] + rows.push({ + service: String(account.service || ""), + instance: String(account.instance || ""), + name: String(account.displayName || ""), + selected: String(account.service || "") === String(selectedService || ""), + status: String(account.statusText || "") + }) + } + return rows +} + +// Accept either the full unit name or the bare instance, because a script author +// reaches for "personal" before "onedrive@personal.service". +// +// The full unit name is the unambiguous form, so it is matched across EVERY +// account before any instance is considered. Scanning account-by-account and +// testing both keys per account made the answer depend on discovery order: an +// instance match on the first account would beat an exact unit-name match on the +// second, and the control would then act on the wrong account. +// +// Returns the matched account's service name, or "" when nothing matches. +function resolveAccountTarget(accounts, target) { + var list = accounts || [] + var wanted = String(target || "") + // The plain account's instance IS "", so an unset argument would otherwise + // match it and silently retarget every later control at the default account. + // (No account key can equal "" today, so this guard cannot currently be + // observed failing -- it is here so that stops being an accident.) + if (wanted === "") return "" + var index + for (index = 0; index < list.length; index++) { + if (String(list[index].service || "") === wanted) return wanted + } + for (index = 0; index < list.length; index++) { + if (String(list[index].instance || "") === wanted) return String(list[index].service || "") + } + return "" +} + +function reconcilePlan(current, discovered) { + var present = Array.isArray(current) ? current : [] + var rows = Array.isArray(discovered) ? discovered : [] + var plan = { updates: [], appends: [], removes: [] } + var seen = {} + + for (var index = 0; index < rows.length; index++) { + var row = rows[index] + if (!row || typeof row !== "object") continue + var service = String(row.service || "") + if (service === "") continue + if (seen[service]) continue // discovery should not repeat, but never trust it + seen[service] = true + var existing = present.indexOf(service) + if (existing === -1) plan.appends.push(row) + else plan.updates.push({ index: existing, row: row }) + } + + // Descending, so applying them cannot invalidate the indices that follow. + for (var scan = present.length - 1; scan >= 0; scan--) { + if (!seen[present[scan]]) plan.removes.push(scan) + } + return plan +} + function filePath(url) { return decodeURIComponent(String(url || "").replace(/^file:\/\//, "")) } @@ -304,8 +869,24 @@ if (typeof module !== "undefined") { folderName: folderName, tooltip: tooltip, heroMeta: heroMeta, - filePath: filePath, - notificationActionArgv: notificationActionArgv, - notificationCommand: notificationCommand + accountName: accountName, + accountStateKind: accountStateKind, + accountState: accountState, + aggregateAccounts: aggregateAccounts, + aggregateTooltip: aggregateTooltip, + composeNotification: composeNotification, + elideStatus: elideStatus, + inheritedPlainText: inheritedPlainText, + barState: barState, + badgeKind: badgeKind, + openSelection: openSelection, + needsAttention: needsAttention, + badgeGlyph: badgeGlyph, + nextPollIndex: nextPollIndex, + cloudDecision: cloudDecision, + accountRows: accountRows, + resolveAccountTarget: resolveAccountTarget, + reconcilePlan: reconcilePlan, + filePath: filePath } } diff --git a/Panel.qml b/Panel.qml index 077836c..3b555fb 100644 --- a/Panel.qml +++ b/Panel.qml @@ -107,7 +107,10 @@ Panel { function open() { root.controller.show() - oneDrive.refresh(false) + // Before anything reads the selection: the panel's own bindings, and every + // control in it, follow selectedAccount. + oneDrive.selectBadgedAccount() + oneDrive.refreshSelected() oneDrive.retryStaleQuotaOnOpen() Qt.callLater(function() { if (root.opened) { @@ -152,6 +155,16 @@ Panel { } onPanelStyleChanged: ensureCursor() + // Switching account changes which rows exist and whether they are enabled, so + // the keyboard cursor is revalidated and the view returns to the top rather + // than leaving the reader halfway down a different account's activity list. + function selectAccount(service) { + if (!service || service === oneDrive.selectedService) return + oneDrive.selectAccount(service) + if (panelScroll) panelScroll.contentY = 0 + ensureCursor() + } + Service { id: oneDrive settings: root.settings @@ -165,6 +178,8 @@ Panel { function onBusyChanged() { root.ensureCursor() } function onResumeAtChanged() { root.ensureCursor() } function onActiveChanged() { root.ensureCursor() } + function onSelectedServiceChanged() { root.ensureCursor() } + function onAccountCountChanged() { root.ensureCursor() } } BarIconButton { @@ -217,6 +232,46 @@ Panel { width: panelScroll.width spacing: Style.space(10) + // Account selector. Hidden entirely for a single account, where it + // contributes no height and no spacing, so both panel styles keep + // their current layout. The hero title stays "OneDrive" either way -- + // the selected segment already carries identity. + Flickable { + id: accountTabsScroll + visible: oneDrive.accountCount > 1 + width: parent.width + height: visible ? accountTabs.implicitHeight : 0 + contentWidth: accountTabs.implicitWidth + contentHeight: height + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.HorizontalFlick + // Only this row scrolls sideways when the labels do not fit; the + // panel itself never widens. + interactive: contentWidth > width + + Row { + id: accountTabs + spacing: Style.space(4) + readonly property real segmentWidth: { + var count = Math.max(1, oneDrive.accountCount) + var available = accountTabsScroll.width - spacing * (count - 1) + return Math.max(Style.space(88), available / count) + } + + Repeater { + model: oneDrive.accounts + delegate: AccountTab { + required property var modelData + account: modelData + width: accountTabs.segmentWidth + selected: modelData && modelData.service === oneDrive.selectedService + onActivated: root.selectAccount(modelData.service) + } + } + } + } + Item { id: headerItem visible: oneDrive.authenticated @@ -249,6 +304,7 @@ Panel { visible: oneDrive.installed && oneDrive.serviceAvailable && oneDrive.authenticated Text { + textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: oneDrive.activeState === "activating" ? "Starting" @@ -280,6 +336,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: oneDrive.actionStatus !== "" || oneDrive.lastError !== "" width: parent.width @@ -336,6 +393,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: oneDrive.authenticated && oneDrive.serviceAvailable && !oneDrive.enabled width: parent.width @@ -385,6 +443,7 @@ Panel { implicitHeight: Math.max(storageHeader.implicitHeight, storageValue.implicitHeight) PanelSectionHeader { + textFormat: Text.PlainText id: storageHeader text: "STORAGE" @@ -395,6 +454,7 @@ Panel { } Text { + textFormat: Text.PlainText id: storageValue text: Model.usageShort(oneDrive.usedBytes, oneDrive.quotaBytes, oneDrive.quotaKnown) @@ -433,6 +493,7 @@ Panel { implicitHeight: Math.max(storageFree.implicitHeight, storageChecked.implicitHeight) Text { + textFormat: Text.PlainText id: storageFree text: oneDrive.quotaKnown @@ -447,6 +508,7 @@ Panel { } Text { + textFormat: Text.PlainText id: storageChecked text: Model.checkedText(oneDrive.quotaCheckedTs) @@ -460,6 +522,7 @@ Panel { } Text { + textFormat: Text.PlainText id: quotaWarning visible: oneDrive.quotaChecking || oneDrive.quotaError !== "" @@ -506,6 +569,7 @@ Panel { implicitHeight: Math.max(activityHeader.implicitHeight, activityMeta.implicitHeight) PanelSectionHeader { + textFormat: Text.PlainText id: activityHeader text: "ACTIVITY" @@ -516,6 +580,7 @@ Panel { } Text { + textFormat: Text.PlainText id: activityMeta text: Model.syncMeta(oneDrive.lastSyncTs) || Model.activityMeta(root.activityRows) @@ -530,6 +595,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: root.activityRows.length === 0 width: parent.width @@ -567,6 +633,7 @@ Panel { spacing: Style.space(6) PanelSectionHeader { + textFormat: Text.PlainText text: oneDrive.active ? "PAUSE FOR" : "RESUME IN" foreground: root.foreground @@ -754,6 +821,7 @@ Panel { spacing: Style.space(9) Text { + textFormat: Text.PlainText id: actionRowGlyph text: actionRow.icon @@ -776,6 +844,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: actionRow.title @@ -786,6 +855,7 @@ Panel { } Text { + textFormat: Text.PlainText Layout.fillWidth: true text: actionRow.subtitle @@ -865,6 +935,7 @@ Panel { spacing: Style.space(7) Text { + textFormat: Text.PlainText text: Model.activityGlyph(activityRow.rowData) color: root.foreground @@ -878,6 +949,7 @@ Panel { spacing: Style.space(1) Text { + textFormat: Text.PlainText Layout.fillWidth: true text: activityRow.title @@ -889,6 +961,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: activityRow.detail !== "" Layout.fillWidth: true @@ -901,6 +974,7 @@ Panel { } Text { + textFormat: Text.PlainText text: Model.relativeTime(activityRow.rowData && activityRow.rowData.ts || 0) color: root.dim @@ -912,6 +986,68 @@ Panel { } } + component AccountTab: CursorSurface { + id: accountTab + property var account: null + property bool selected: false + readonly property bool keyboardEnabled: true + signal activated() + function keyboardActivate() { activated() } + + // No glyph until this account has reported: its default values would + // otherwise render as the missing-client mark, which is the same drift the + // bar's checking gate exists to prevent. + readonly property string stateKind: account && account.initialized + ? Model.badgeKind(Model.accountStateKind(account)) : "" + + foreground: root.foreground + // The selected segment carries the border; the rest read as quiet labels. + bordered: accountTab.selected + hasCursor: (accountTabMouse.containsMouse || root.cursorItem === accountTab) + implicitHeight: Style.space(30) + height: implicitHeight + + Row { + anchors.centerIn: parent + spacing: Style.space(5) + + Text { + + textFormat: Text.PlainText + anchors.verticalCenter: parent.verticalCenter + visible: accountTab.stateKind !== "" + text: Model.badgeGlyph(accountTab.stateKind) + color: accountTab.stateKind === "attention" || accountTab.stateKind === "login" + ? Color.urgent + : (accountTab.stateKind === "syncing" ? Color.accent : root.dim) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + Text { + + textFormat: Text.PlainText + anchors.verticalCenter: parent.verticalCenter + text: accountTab.account + ? Model.accountName(accountTab.account.instance, accountTab.account.description) + : "" + color: accountTab.selected ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + width: Math.min(implicitWidth, Math.max(0, accountTab.width - Style.space(22))) + } + } + + MouseArea { + id: accountTabMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: accountTab.activated() + } + } + component ActionChip: CursorSurface { id: actionChip property string text: "" @@ -935,6 +1071,7 @@ Panel { spacing: Style.space(6) Text { + textFormat: Text.PlainText id: actionChipGlyph anchors.verticalCenter: parent.verticalCenter @@ -953,6 +1090,7 @@ Panel { } Text { + textFormat: Text.PlainText // Keep the label inside the chip border: give it only the width the // chip can spare, shrinking the font slightly before eliding. @@ -1011,6 +1149,7 @@ Panel { spacing: Style.space(8) Text { + textFormat: Text.PlainText id: compactActionGlyph text: compactRow.icon @@ -1029,6 +1168,7 @@ Panel { } Text { + textFormat: Text.PlainText Layout.fillWidth: true text: compactRow.title @@ -1039,6 +1179,7 @@ Panel { } Text { + textFormat: Text.PlainText visible: compactRow.meta !== "" text: compactRow.meta diff --git a/README.md b/README.md index 9a77226..b64f184 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,15 @@ pause/resume — in a panel like Omarchy's own Dropbox widget. - **Live status** — monitoring, syncing, paused, or needs attention, with the file currently transferring and its progress. Interruptions show as "retrying", never as a frozen percentage. +- **Multiple accounts** — every configured account is discovered from its own + systemd unit, with a selector row in the panel, per-account pause, login and + repair, and one bar badge showing whichever account most needs attention. + A single-account setup keeps the same commands, cache and lock paths, tooltip, + panel layout and notification text. - **Notifications** when OneDrive fails, needs a resync or reauthentication, recovers, or storage passes 90% full. Clicking one opens the panel or - starts the repair. Optional. + starts the repair. Events from several accounts are grouped into one + notification that names them. Optional. - **Guided repair** — opens the CLI's own interactive `--resync` flow in a terminal, which asks before it touches anything. - **Pause and resume** with 15-minute, 1-hour and 4-hour timed pauses. @@ -40,7 +46,9 @@ pause/resume — in a panel like Omarchy's own Dropbox widget. ## Controls - Left click toggles the panel; middle click opens the OneDrive folder; - right click refreshes cloud storage. + right click refreshes cloud storage. With several accounts, the panel's + selector row chooses which account every control acts on; there is + deliberately no pause-everything button. - `↑` `↓` move, `Enter` activates. `R` refreshes storage, `F` verifies sync, `P` pauses or resumes, `O` opens the folder, `W` opens OneDrive on the web, `L` opens login, `Esc` closes. @@ -51,6 +59,16 @@ contacted only by **Refresh storage** (`onedrive --display-quota`) and never automatic) — plus one storage retry when you open the panel onto a failed check older than five minutes. +With several accounts the refresh interval is shared rather than multiplied: +in steady state each account is polled once per interval, staggered across it, +and only one account is read at a time. At startup a faster ramp runs until every +account has reported once. Cloud checks stay manual and run one at a time across +all accounts. + +Accounts are found by reading each `onedrive` systemd user unit's own +`ExecStart` for its `--confdir`, so an instance pointed at an unrelated +directory is still found correctly. Nothing is guessed from unit names. + ## Requirements - Omarchy 4 (Quattro) diff --git a/Service.qml b/Service.qml index 061795a..acf0b23 100644 --- a/Service.qml +++ b/Service.qml @@ -1,79 +1,61 @@ import QtQuick +import QtQml.Models import Quickshell import Quickshell.Io import "Model.js" as Model - +import "Commands.js" as Commands + +// Coordinator over every discovered OneDrive account. +// +// It owns discovery, selection, aggregation and scheduling; each Account owns +// its own state and processes. The panel and bar widget bind to the selected +// account through the forwarding block near the bottom, so a single-account +// install behaves exactly as it did before discovery existed. Item { id: root property var settings: ({}) - property bool installed: false - property bool serviceAvailable: false - property bool running: false - property bool enabled: false - property string activeState: "" - property bool serviceFailed: false - property bool resyncRequired: false - property bool authenticated: false - property bool reauthRequired: false - property bool syncing: false - property string syncStage: "" - property int _desired: -1 - readonly property bool active: _desired === -1 - ? (running || activeState === "activating") : _desired === 1 - property bool refreshing: false - property string statusText: "Checking…" - property string syncDir: "" - property string syncMode: "Two-way" - property string clientVersion: "" - property double resumeAt: 0 - property double lastSyncTs: 0 - property double usedBytes: 0 - property double quotaBytes: 0 - property bool quotaKnown: false - property double quotaCheckedTs: 0 - property string quotaError: "" - property string remoteStatus: "Not checked" - property double syncStatusCheckedTs: 0 - property string syncStatusError: "" - property double remoteCheckedTs: 0 - property string remoteError: "" - property var files: [] - property var activity: [] - property string actionStatus: "" - property string lastError: "" + property var _accountObjects: [] + property int _aggregateRevision: 0 + + readonly property var accounts: _accountObjects + readonly property int accountCount: accounts.length + property string selectedService: "" + + readonly property var selectedAccount: { + var found = accountForService(selectedService) + if (found) return found + return accounts.length > 0 ? accounts[0] : null + } + + readonly property var aggregate: { + void(_aggregateRevision) + return Model.aggregateAccounts(accounts) + } + + // Every account has been polled at least once, whatever the outcome. Distinct + // from aggregate.initialized, which asks whether any account produced a usable + // sample: an account whose helper always fails is attempted but never + // initialized, and the startup hold has to end for it. + readonly property bool allAttempted: { + void(_aggregateRevision) + for (var index = 0; index < accounts.length; index++) { + if (!accounts[index].attempted) return false + } + return accounts.length > 0 + } readonly property bool notificationsEnabled: { var value = setting("notifications", true) return value === true || String(value).toLowerCase() === "true" } readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 30, 10, 3600) - readonly property int recentFileLimit: intSetting("recentFileLimit", 20, 5, 50) readonly property string helperPath: Model.filePath(Qt.resolvedUrl("onedrive-status.py")) - readonly property bool busy: statusProcess.running || controlProcess.running - || cancelTimerProcess.running || scheduleTimerProcess.running - readonly property string resumeUnit: "omaonedrive-resume" - readonly property bool cloudChecking: _activeCloudMode !== "" && statusProcess.running - readonly property bool quotaChecking: _activeCloudMode === "quota" && statusProcess.running - readonly property bool fullStatusChecking: _activeCloudMode === "sync-status" && statusProcess.running - - // Must match QUOTA_TIMEOUT_SECONDS / SYNC_STATUS_TIMEOUT_SECONDS in onedrive-status.py. - readonly property int cloudTimeoutSec: 30 - readonly property int cloudRetryAfterSec: 300 - - property string _cloudRequested: "" - property string _activeCloudMode: "" - property string _statusOutput: "" - property string _statusError: "" - property string _controlOutput: "" - property string _controlError: "" - property string _timerOutput: "" - property string _timerError: "" - property string _afterTimerCancel: "" - property int _pauseMinutes: 0 - property int _controlDesired: -1 - property bool _scheduleRecovery: false + + property string discoveryError: "" + property bool _discoverySettled: true + function setting(name, fallback) { var value = settings ? settings[name] : undefined @@ -86,442 +68,552 @@ Item { return Math.max(minimum, Math.min(maximum, value)) } - function refresh(remote) { - if (remote === true) { - checkQuota() - return + function accountForService(service) { + for (var index = 0; index < _accountObjects.length; index++) { + if (_accountObjects[index].service === service) return _accountObjects[index] } - if (statusProcess.running || helperPath === "") return - startStatusProcess("") - } - - function checkQuota() { - requestCloudCheck("quota") - } - - // Opening the panel is explicit user intent, so a failed storage result - // older than cloudRetryAfterSec is retried once on open. The decision is - // deferred until the next status poll returns, because at open() time the - // in-memory state may predate the poll the panel just started. - // quotaCheckedTs updates even on failure, which blocks another retry until - // the window passes. Verify sync is never retried automatically — it is - // the expensive full-drive check and stays strictly manual. - property bool _quotaRetryQueued: false - - function retryStaleQuotaOnOpen() { - _quotaRetryQueued = true - } - - function maybeRetryStaleQuota() { - if (quotaError === "" || quotaChecking) return - if (Date.now() / 1000 - quotaCheckedTs < cloudRetryAfterSec) return - checkQuota() - } - - function checkFullStatus() { - requestCloudCheck("sync-status") - } - - function requestCloudCheck(mode) { - if (helperPath === "") return - if (statusProcess.running) { - _cloudRequested = mode - return + return null + } + + // `withQuotaRetry` is the panel's behaviour, not selection's: opening the + // panel onto a stale failed quota check retries it once. Automation selecting + // an account merely to target a control must not silently contact Microsoft, + // so IPC passes false. + function selectAccount(service, withQuotaRetry) { + var found = accountForService(service) + if (!found) return + selectedService = found.service + if (!routinePollRunning()) found.refresh(false) + if (withQuotaRetry !== false) found.retryStaleQuotaOnOpen() + } + + // --- discovery ------------------------------------------------------------ + + // Reconcile by the stable service key rather than clearing and rebuilding: + // recreating delegates would drop in-flight processes and the notification + // edge history that decides whether a condition is new. + function applyDiscovery(rows) { + var current = [] + for (var scan = 0; scan < descriptors.count; scan++) current.push(descriptors.get(scan).service) + + var plan = Model.reconcilePlan(current, rows) + for (var update = 0; update < plan.updates.length; update++) { + var descriptor = normalizeDescriptor(plan.updates[update].row) + var existing = descriptors.get(plan.updates[update].index) + // A unit repointed at a different config directory is a different account + // behind the same name. Keeping the delegate preserves its processes, but + // its quota, file list, auth flag and notification edge history now belong + // to the previous directory and must not be shown as this one's. + // Only a real repoint: "" means "not yet known", and the seeded descriptor + // always starts that way, so treating it as a change wiped the first + // sample and the edge latches on every startup. + if (existing.confdir !== descriptor.confdir) { + var account = accountForService(descriptor.service) + if (existing.confdir !== "" && descriptor.confdir !== "") { + if (account) account.forgetSample() + } else if (account) { + // "" -> a real directory is the startup seed learning its identity, + // not a repoint: there is no sample to wipe, and wiping one reset the + // edge latches on every startup. But a poll already in flight was + // started against the CLIENT's default directory, which is only the + // same directory if this unit does not override it. Refuse that reply + // rather than attach another account's sync directory and quota to + // this one. + account.discardInFlight() + } + } + descriptors.set(plan.updates[update].index, descriptor) } - startStatusProcess(mode) - } - - function startStatusProcess(cloudMode) { - _activeCloudMode = cloudMode - _statusOutput = "" - _statusError = "" - refreshing = true - var command = ["python3", helperPath, "--limit", String(recentFileLimit)] - if (cloudMode === "quota") command.push("--quota") - else if (cloudMode === "sync-status") command.push("--sync-status") - if (cloudMode !== "") { - actionStatusTimer.stop() - actionStatus = (cloudMode === "quota" ? "Refreshing storage" : "Verifying sync") - + "… may take up to " + String(cloudTimeoutSec) + "s" + for (var append = 0; append < plan.appends.length; append++) { + descriptors.append(normalizeDescriptor(plan.appends[append])) } - statusProcess.command = command - statusProcess.running = true - } - - function notify(urgency, summary, body) { - if (!notificationsEnabled) return - Quickshell.execDetached(Model.notificationCommand(urgency, summary, body, "")) - } - - function notifyWithAction(urgency, summary, body, behavior) { - if (!notificationsEnabled) return - Quickshell.execDetached(Model.notificationCommand(urgency, summary, body, behavior)) - } - - function applyStatus(raw) { - var parsed = Model.parseStatus(raw) - if (!parsed.ok) { - lastError = parsed.lastError || "Failed to read OneDrive status" - return + for (var remove = 0; remove < plan.removes.length; remove++) { + descriptors.remove(plan.removes[remove]) } - var wasFailed = serviceFailed - var wasResync = resyncRequired - var wasReauth = reauthRequired - var hadAttention = serviceFailed || resyncRequired || reauthRequired - var wasStorageSevere = Model.usageSevere(usedBytes, quotaBytes, quotaKnown) - installed = parsed.installed === true - serviceAvailable = parsed.serviceAvailable === true - running = parsed.running === true - enabled = parsed.enabled === true - activeState = String(parsed.activeState || "") - serviceFailed = parsed.serviceFailed === true - resyncRequired = parsed.resyncRequired === true - authenticated = parsed.authenticated === true - reauthRequired = parsed.reauthRequired === true - syncing = parsed.syncing === true - syncStage = String(parsed.syncStage || "") - if (_desired !== -1 && running === (_desired === 1)) _desired = -1 - statusText = String(parsed.statusText || (installed ? "Sync paused" : "Not installed")) - syncDir = String(parsed.syncDir || "") - syncMode = String(parsed.syncMode || "Two-way") - clientVersion = String(parsed.clientVersion || "") - resumeAt = Number(parsed.resumeAt || 0) - lastSyncTs = Number(parsed.lastSyncTs || 0) - usedBytes = Number(parsed.usedBytes || 0) - quotaBytes = Number(parsed.quotaBytes || 0) - quotaKnown = parsed.quotaKnown === true - quotaCheckedTs = Number(parsed.quotaCheckedTs || 0) - quotaError = String(parsed.quotaError || "") - remoteStatus = String(parsed.remoteStatus || "Not checked") - syncStatusCheckedTs = Number(parsed.syncStatusCheckedTs || 0) - syncStatusError = String(parsed.syncStatusError || "") - remoteCheckedTs = Number(parsed.remoteCheckedTs || 0) - remoteError = String(parsed.remoteError || "") - files = parsed.files || [] - activity = parsed.activity || [] - lastError = String(parsed.lastError || "") - - if (resyncRequired && !wasResync) - notifyWithAction("critical", "OneDrive needs a resync", - "Syncing stopped until the resync repair runs.", - "repair") - else if (serviceFailed && !wasFailed) - notifyWithAction("critical", "OneDrive sync failed", - lastError !== "" ? lastError : "The OneDrive service entered a failed state.", - "open") - if (reauthRequired && !wasReauth) - notifyWithAction("critical", "OneDrive needs reauthentication", - "Sign in again to keep syncing.", - "open") - if (hadAttention && !serviceFailed && !resyncRequired && !reauthRequired) - notify("normal", "OneDrive recovered", "Syncing is healthy again.") - if (!wasStorageSevere && Model.usageSevere(usedBytes, quotaBytes, quotaKnown)) - notify("normal", "OneDrive storage almost full", - Model.freeText(usedBytes, quotaBytes, quotaKnown) + " of " - + Model.formatBytes(quotaBytes) + " remains.") - } - - function elideStatus(text) { - var value = String(text || "").replace(/\s+/g, " ").trim() - return value.length > 180 ? value.substring(0, 177) + "…" : value - } - - function login() { - if (!installed) return - Quickshell.execDetached(["omarchy-launch-terminal", "onedrive"]) - actionStatus = "Opened OneDrive login" - actionStatusTimer.restart() - } - - function reauthenticate() { - if (!installed || running) return - Quickshell.execDetached(["omarchy-launch-terminal", "onedrive", "--reauth"]) - actionStatus = "Opened OneDrive reauthentication" - actionStatusTimer.restart() - } - - function repairResync() { - if (!installed || running || busy) return - Quickshell.execDetached(["omarchy-launch-terminal", "onedrive", "--sync", "--resync"]) - actionStatus = "Opened OneDrive resync repair" - actionStatusTimer.restart() - } - - function openWeb() { - Quickshell.execDetached(["uwsm-app", "--", "xdg-open", "https://onedrive.live.com/"]) - } - - function pause() { - if (busy) return - _pauseMinutes = 0 - cancelResumeTimer("pause") - } - - function pauseFor(minutes) { - var requested = parseInt(String(minutes), 10) - if (!isFinite(requested) || requested <= 0) return - var duration = Math.max(5, Math.min(1440, requested)) - if (!installed || !serviceAvailable || !authenticated || busy - || serviceFailed || resyncRequired || reauthRequired) return - _pauseMinutes = duration - cancelResumeTimer("pause") - } - - function resume() { - if (!authenticated) { - login() - return + + if (descriptors.count === 0) descriptors.append(defaultDescriptor()) + // Reconciliation may have removed the account holding the cloud slot, or the + // one a queued entry belongs to. Re-check once the model has settled. + Qt.callLater(function() { root.cloudFinished() }) + // A removed selected account falls back to the first discovered one. + if (accountForService(selectedService) === null) { + selectedService = descriptors.count > 0 ? descriptors.get(0).service : "" } - if (busy) return - _pauseMinutes = 0 - cancelResumeTimer("resume") } - function toggleRunning() { - if (active) pause() - else resume() + function normalizeDescriptor(row) { + return { + service: String(row.service || ""), + instance: String(row.instance || ""), + confdir: String(row.confdir || ""), + description: String(row.description || "") + } } - function runControl(command, desired) { - if (!installed || !serviceAvailable || controlProcess.running) return - _desired = desired - _controlDesired = desired - _controlOutput = "" - _controlError = "" - controlProcess.command = command - controlProcess.running = true + // The compatibility guarantee: with no template instances this is what + // discovery returns, and it is also what we fall back to if discovery fails. + function defaultDescriptor() { + return { + service: Commands.DEFAULT_SERVICE, + instance: "", + confdir: "", + description: "" + } } - function cancelResumeTimer(afterAction) { - _afterTimerCancel = afterAction - _timerOutput = "" - _timerError = "" - cancelTimerProcess.command = [ - "systemctl", "--user", "stop", - resumeUnit + ".timer", resumeUnit + ".service" - ] - cancelTimerProcess.running = true + function reloadAccounts() { + if (discoveryProcess.running || helperPath === "") return + discoveryProcess.command = Commands.listAccounts(helperPath) + _discoverySettled = false + discoveryProcess.running = true } - function scheduleResume(minutes) { - _timerOutput = "" - _timerError = "" - scheduleTimerProcess.command = [ - "systemd-run", "--user", - "--unit=" + resumeUnit, - "--description=Resume OneDrive after timed pause", - "--on-active=" + String(minutes) + "m", - "--timer-property=AccuracySec=1s", - "--collect", - "/usr/bin/systemctl", "--user", "start", "onedrive.service" - ] - scheduleTimerProcess.running = true + function trackAccount(object) { + var next = _accountObjects.slice() + next.push(object) + _accountObjects = next + _aggregateRevision++ } - function openFolder() { - if (syncDir !== "") Quickshell.execDetached(["uwsm-app", "--", "xdg-open", syncDir]) + function untrackAccount(object) { + var next = [] + for (var index = 0; index < _accountObjects.length; index++) { + if (_accountObjects[index] !== object) next.push(_accountObjects[index]) + } + _accountObjects = next + _aggregateRevision++ } - function openFile(file) { - if (!file || !file.path) return - Quickshell.execDetached(["uwsm-app", "--", "nautilus", "--select", fileUri(String(file.path))]) - } + // --- scheduling ----------------------------------------------------------- - function fileUri(path) { - var parts = String(path || "").split("/") - for (var index = 0; index < parts.length; index++) parts[index] = encodeURIComponent(parts[index]) - return "file://" + parts.join("/") - } + property int _pollCursor: 0 - Timer { - interval: root.refreshIntervalSec * 1000 - repeat: true - running: true - triggeredOnStart: true - onTriggered: root.refresh(false) + // One routine status process at a time across every account, so N accounts + // cost one subprocess per slot rather than N at once. + function routinePollRunning() { + for (var index = 0; index < _accountObjects.length; index++) { + if (_accountObjects[index].routinePolling) return true + } + return false + } + + // Shape Model.nextPollIndex expects: it must skip an account whose status + // process is busy for ANY reason, not only a routine poll. + function pollCandidates() { + var rows = [] + for (var index = 0; index < _accountObjects.length; index++) { + rows.push({ + routinePolling: _accountObjects[index].routinePolling, + busy: _accountObjects[index].statusBusy, + attempted: _accountObjects[index].attempted, + settling: _accountObjects[index].settling + }) + } + return rows + } + + // An account that has not reported yet takes priority, but the cursor still + // advances, so several unreported accounts interleave instead of the first one + // taking every slot until it gives up. Priority is "has not reported" + // (!initialized), not "not running": a deliberately paused account is a known + // state and must not consume startup slots at all. + function nextAccountToPoll() { + var index = Model.nextPollIndex(pollCandidates(), _pollCursor) + if (index < 0) return null + _pollCursor = (index + 1) % _accountObjects.length + return _accountObjects[index] + } + + function pollNextAccount() { + if (routinePollRunning()) return + var account = nextAccountToPoll() + if (account) account.refresh(false) + } + + // --- cloud check semaphore ------------------------------------------------ + + // Explicit cloud checks are slow (up to 30s) and are never automatic. One at a + // time across all accounts, de-duplicated by (service, mode) so repeated + // clicks cannot queue a backlog. + property var _cloudQueue: [] + + function requestCloud(account, mode) { + if (!account) return + var busyAccount = cloudBusyAccount() + var active = busyAccount + ? { service: busyAccount.service, mode: busyAccount.activeCloudMode } + : null + var decision = Model.cloudDecision( + busyAccount !== null, _cloudQueue, account.service, mode, active) + if (decision === "drop") return + if (decision === "queue") { + var next = _cloudQueue.slice() + next.push({ service: account.service, mode: mode }) + _cloudQueue = next + return + } + account.startCloudCheck(mode) } - Timer { - id: startupRamp - property int ticks: 0 - interval: 2000 - repeat: true - running: true - onTriggered: { - ticks += 1 - if (root.running || ticks >= 15) startupRamp.running = false - else root.refresh(false) + function hasDescriptor(service) { + for (var row = 0; row < descriptors.count; row++) { + if (descriptors.get(row).service === service) return true } + return false + } + + function cloudBusyAccount() { + for (var index = 0; index < _accountObjects.length; index++) { + var account = _accountObjects[index] + // A delegate discovery has already dropped will never report again, so it + // must not hold the shared slot: nothing releases a slot held by an + // account that no longer exists, and every queued account then sat there + // showing "Not checked". + // + // The retry at the end of applyDiscovery is what the harness actually + // pins -- there the delegate is already destroyed by the time it runs. + // This guard covers the case where destruction is deferred past that + // point, which the harness cannot produce and which real Quickshell can. + if (!hasDescriptor(account.service)) continue + // Pending counts as busy: a check deferred behind that account's routine + // poll has already claimed the slot. + if (account.cloudChecking || account.cloudPending) return account + } + return null + } + + function cloudFinished() { + // pollFinished fires for routine polls too; only advance when the shared + // slot is actually free. + if (cloudBusyAccount() !== null) return + if (_cloudQueue.length === 0) return + var next = _cloudQueue.slice() + // Keep taking entries until one belongs to an account that still exists. + // Stopping after a single shift meant an account removed by discovery while + // queued took the whole rest of the queue with it: the entries behind it + // stayed put and no cloud check started, so the accounts waiting on a + // storage figure showed "Not checked" until some later poll happened to + // call back in -- and never, if polling was itself blocked. + while (next.length > 0) { + var entry = next.shift() + var account = accountForService(entry.service) + if (account) { + _cloudQueue = next + account.startCloudCheck(entry.mode) + return + } + } + _cloudQueue = next + } + + // --- notification broker -------------------------------------------------- + + // One popup per polling burst, not one per account. Accounts report + // transitions; this decides how many notifications that becomes. + property var _pendingEvents: [] + property bool _baselineSent: false + + function enqueueTransition(event) { + if (!notificationsEnabled || !event) return + var next = _pendingEvents.slice() + next.push(event) + _pendingEvents = next + // start(), not restart(): restarting on every event made the window mean + // "900ms of quiet", which a staggered scheduler never produces, so each + // account's events flushed separately. The window now runs from the FIRST + // event of a batch and is long enough to span one poll round. + if (!burstTimer.running) burstTimer.start() + } + + function flushTransitions() { + var events = _pendingEvents + _pendingEvents = [] + if (events.length === 0) return + // Checked at SEND time, not only at enqueue: the burst window is up to a + // full refresh interval and the startup hold longer still, so a user who + // turns notifications off inside that window would otherwise still get one. + if (!notificationsEnabled) return + var composed = Model.composeNotification(events, accountCount > 1) + if (!composed) return + _baselineSent = true + // The click command rides inside the notification as a persisted --exec + // hint, so it survives shell and plugin reloads -- the old tracked + // notify-send read the click back from stdout and died with this process. + // That also removes the one-tracked-popup-at-a-time constraint and its + // action-dropping fallback: every popup is fire-and-forget now, and every + // one keeps its click. + Quickshell.execDetached(Commands.notify( + composed.urgency, composed.summary, composed.body, + composed.action, composed.service)) + } + + // What a notification click does, reached over IPC (BarWidget's openAccount + // and repairAccount) because the daemon delivers the click as a fresh + // `omarchy-shell` invocation. Both live here so the harness can drive them. + function openFromNotification(target) { + var found = Model.resolveAccountTarget(accounts, target) + // An account that vanished since the popup fired: still open the panel -- + // the user asked for it -- just on whatever is selected. + if (found !== "" && found !== selectedService) selectAccount(found, false) + } + + function repairFromNotification(target) { + var found = Model.resolveAccountTarget(accounts, target) + // Unlike open, a repair on the WRONG account deletes and re-downloads the + // wrong drive. A stale target repairs nothing rather than whatever + // happens to be selected. + if (found === "") return + // Repair BEFORE selecting: selection refreshes the account, and + // repairResync refuses one that is mid-poll -- the click would silently do + // nothing. The select is only so the panel shows the account being + // repaired if the user opens it next. + var account = accountForService(found) + if (account) account.repairResync() + if (found !== selectedService) selectAccount(found, false) + } + + // --- selected-account facade ---------------------------------------------- + // + // Panel.qml and BarWidget.qml still say `oneDrive.running`. Every forward is + // null-safe: between startup and the first descriptor there is no account. + + readonly property bool installed: selectedAccount ? selectedAccount.installed : false + readonly property bool serviceAvailable: selectedAccount ? selectedAccount.serviceAvailable : false + readonly property bool running: selectedAccount ? selectedAccount.running : false + readonly property bool enabled: selectedAccount ? selectedAccount.enabled : false + readonly property string activeState: selectedAccount ? selectedAccount.activeState : "" + readonly property bool serviceFailed: selectedAccount ? selectedAccount.serviceFailed : false + readonly property bool resyncRequired: selectedAccount ? selectedAccount.resyncRequired : false + readonly property bool authenticated: selectedAccount ? selectedAccount.authenticated : false + readonly property bool reauthRequired: selectedAccount ? selectedAccount.reauthRequired : false + readonly property bool syncing: selectedAccount ? selectedAccount.syncing : false + readonly property string syncStage: selectedAccount ? selectedAccount.syncStage : "" + readonly property bool active: selectedAccount ? selectedAccount.active : false + readonly property bool refreshing: selectedAccount ? selectedAccount.refreshing : false + readonly property string statusText: selectedAccount ? selectedAccount.statusText : "Checking…" + readonly property string syncDir: selectedAccount ? selectedAccount.syncDir : "" + readonly property string syncMode: selectedAccount ? selectedAccount.syncMode : "Two-way" + readonly property string clientVersion: selectedAccount ? selectedAccount.clientVersion : "" + readonly property double resumeAt: selectedAccount ? selectedAccount.resumeAt : 0 + readonly property double lastSyncTs: selectedAccount ? selectedAccount.lastSyncTs : 0 + readonly property double usedBytes: selectedAccount ? selectedAccount.usedBytes : 0 + readonly property double quotaBytes: selectedAccount ? selectedAccount.quotaBytes : 0 + readonly property bool quotaKnown: selectedAccount ? selectedAccount.quotaKnown : false + readonly property double quotaCheckedTs: selectedAccount ? selectedAccount.quotaCheckedTs : 0 + readonly property string quotaError: selectedAccount ? selectedAccount.quotaError : "" + readonly property string remoteStatus: selectedAccount ? selectedAccount.remoteStatus : "Not checked" + readonly property double syncStatusCheckedTs: selectedAccount ? selectedAccount.syncStatusCheckedTs : 0 + readonly property string syncStatusError: selectedAccount ? selectedAccount.syncStatusError : "" + readonly property double remoteCheckedTs: selectedAccount ? selectedAccount.remoteCheckedTs : 0 + readonly property string remoteError: selectedAccount ? selectedAccount.remoteError : "" + readonly property var files: selectedAccount ? selectedAccount.files : [] + readonly property var activity: selectedAccount ? selectedAccount.activity : [] + readonly property string actionStatus: selectedAccount ? selectedAccount.actionStatus : "" + // A discovery failure is non-destructive, but the user should still learn the + // account list may be stale; the account's own error takes precedence. + readonly property string lastError: { + if (selectedAccount && selectedAccount.lastError !== "") return selectedAccount.lastError + return discoveryError + } + readonly property bool busy: selectedAccount ? selectedAccount.busy : false + readonly property bool cloudChecking: selectedAccount ? selectedAccount.cloudChecking : false + readonly property bool quotaChecking: selectedAccount ? selectedAccount.quotaChecking : false + readonly property bool fullStatusChecking: selectedAccount ? selectedAccount.fullStatusChecking : false + // Forwarded from the selected account, which carries the comment tying it to + // the helper's own timeout constants. A second literal here would drift. + readonly property int cloudTimeoutSec: selectedAccount ? selectedAccount.cloudTimeoutSec : 30 + + // A cloud check is explicit user intent and is serialised by its own + // semaphore; a routine refresh goes through the shared slot, so IPC, the panel + // and the scheduler cannot each start a helper at the same time. + function refresh(remote) { + if (!selectedAccount) return + if (remote === true) { selectedAccount.refresh(true); return } + refreshSelected() + } + // Called when the panel is opened from the bar: point it at the account the + // badge is blaming, so the controls act on what the user just clicked about. + function selectBadgedAccount() { + var current = selectedAccount + var target = Model.openSelection(aggregate, + current ? Model.accountStateKind(current) : "") + if (target !== "" && target !== selectedService) selectAccount(target, false) + } + + function refreshSelected() { + if (selectedAccount && !routinePollRunning()) selectedAccount.refresh(false) + } + function checkQuota() { if (selectedAccount) selectedAccount.checkQuota() } + function checkFullStatus() { if (selectedAccount) selectedAccount.checkFullStatus() } + function retryStaleQuotaOnOpen() { if (selectedAccount) selectedAccount.retryStaleQuotaOnOpen() } + function login() { if (selectedAccount) selectedAccount.login() } + function reauthenticate() { if (selectedAccount) selectedAccount.reauthenticate() } + function repairResync() { if (selectedAccount) selectedAccount.repairResync() } + function openWeb() { if (selectedAccount) selectedAccount.openWeb() } + function openFolder() { if (selectedAccount) selectedAccount.openFolder() } + function openFile(file) { if (selectedAccount) selectedAccount.openFile(file) } + function pause() { if (selectedAccount) selectedAccount.pause() } + function pauseFor(minutes) { if (selectedAccount) selectedAccount.pauseFor(minutes) } + function resume() { if (selectedAccount) selectedAccount.resume() } + function toggleRunning() { if (selectedAccount) selectedAccount.toggleRunning() } + + // --- wiring --------------------------------------------------------------- + + ListModel { id: descriptors } + + Instantiator { + id: accountInstances + model: descriptors + delegate: Account { + // Bind the model roles onto Account's OWN properties. Redeclaring them as + // `required property string service` here SHADOWS the base's, producing a + // split brain: reads from outside the object see the model role, but every + // read inside Account.qml -- id-qualified, unqualified, or in a binding -- + // sees the base default. Status reads went through JS and looked correct + // while every control, timer and login vector silently targeted + // onedrive.service. Verified with a minimal qml6 reproduction. + required property var model + service: model.service + instance: model.instance + confdir: model.confdir + description: model.description + settings: root.settings + coordinator: root + onAccountStateChanged: root._aggregateRevision++ + onPollFinished: root.cloudFinished() + onTransition: function(event) { root.enqueueTransition(event) } + } + onObjectAdded: function(index, object) { root.trackAccount(object) } + onObjectRemoved: function(index, object) { root.untrackAccount(object) } } + // A polling burst is roughly one scheduler slot. Until every account has + // reported once, the window is held open so a startup round of pre-existing + // problems becomes ONE baseline notification rather than N. Timer { - id: delayedRefresh - interval: 750 + id: burstTimer + property int heldRounds: 0 + // One poll round, because that is how long it takes every account to be + // sampled once and therefore how long a related set of transitions takes to + // arrive. A single account has nothing to wait for and keeps the short + // window, so its notifications are as prompt as they were before. + // A full poll round, uncapped: capping at 30s meant a 60-second refresh + // interval spread one round's events across two windows and produced two + // popups for the same round. + interval: root.accountCount > 1 ? Math.max(900, root.refreshIntervalSec * 1000) : 900 repeat: false - onTriggered: root.refresh(false) - } - - Timer { - id: settleTimer - property int ticks: 0 - interval: 1200 - repeat: true onTriggered: { - ticks += 1 - root.refresh(false) - if (ticks >= 5) { - ticks = 0 - stop() - root._desired = -1 + // Hold the window open until the first round is over, so a startup round of + // pre-existing problems becomes ONE baseline notification rather than N. + // Gated on allAttempted, not aggregate.initialized: the latter flips as + // soon as the FIRST account reports, which released the hold immediately + // and produced one popup per account -- exactly what this prevents. + if (!root.allAttempted && !root._baselineSent && heldRounds < 12) { + heldRounds += 1 + burstTimer.restart() + return } + heldRounds = 0 + root.flushTransitions() } } - Timer { - id: actionStatusTimer - interval: 2500 - repeat: false - onTriggered: root.actionStatus = "" - } - Process { - id: statusProcess + id: discoveryProcess running: false command: [] - stdout: StdioCollector { - id: statusStdout - waitForEnd: true - onStreamFinished: root._statusOutput = text - } - stderr: StdioCollector { - id: statusStderr - waitForEnd: true - onStreamFinished: root._statusError = text + stdout: StdioCollector { id: discoveryStdout; waitForEnd: true } + stderr: StdioCollector { id: discoveryStderr; waitForEnd: true } + // Discovery is a Process like any other, so it too can stop without ever + // reporting an exit. Without this, a missing python3 left `discoveryError` + // empty for ever: the user saw one account and nothing at all to say the + // list might be wrong. + onRunningChanged: { + if (!running) Qt.callLater(function() { + if (root._discoverySettled) return + root._discoverySettled = true + root.discoveryError = "Could not run the OneDrive status helper" + }) } onExited: function(exitCode) { - var cloudMode = root._activeCloudMode - root.refreshing = false - var stdout = String(statusStdout.text || root._statusOutput || "") - var stderr = String(statusStderr.text || root._statusError || "") - if (exitCode === 0) root.applyStatus(stdout) - else root.lastError = root.elideStatus(stderr || stdout || "Could not read OneDrive status") - if (cloudMode !== "") { - if (exitCode !== 0) root.actionStatus = root.lastError - else if (cloudMode === "quota") - root.actionStatus = root.quotaError === "" ? "Storage refreshed" : root.quotaError - else root.actionStatus = root.syncStatusError === "" - ? "Sync verified" : root.syncStatusError - actionStatusTimer.restart() + root._discoverySettled = true + if (exitCode !== 0) { + // Non-destructive: keep whatever accounts we already have. The startup + // seed and applyDiscovery's own floor both guarantee there is at least + // one, so there is nothing to re-seed here -- an earlier version tried, + // in a branch that could never be reached. + root.discoveryError = String(discoveryStderr.text || "").trim() + || "Could not list OneDrive accounts" + return } - root._activeCloudMode = "" - if (root._cloudRequested !== "") { - var requested = root._cloudRequested - root._cloudRequested = "" - Qt.callLater(function() { root.requestCloudCheck(requested) }) + var rows = null + try { + var parsed = JSON.parse(String(discoveryStdout.text || "")) + if (Array.isArray(parsed)) rows = parsed + } catch (error) { + rows = null } - if (root._quotaRetryQueued) { - root._quotaRetryQueued = false - Qt.callLater(function() { root.maybeRetryStaleQuota() }) + if (rows === null) { + // Unparseable output is "could not look", not "found nothing". Treating + // it as an empty result would remove every account -- more destructive + // than a non-zero exit, which is handled above -- and it would repeat on + // every discovery tick. + root.discoveryError = "Could not read the account list" + return } + root.discoveryError = "" + if (rows.length === 0) rows = [root.defaultDescriptor()] + root.applyDiscovery(rows) } } - Process { - id: cancelTimerProcess - running: false - command: [] - stdout: StdioCollector { waitForEnd: true } - stderr: StdioCollector { waitForEnd: true } - onExited: function(exitCode) { - var action = root._afterTimerCancel - root._afterTimerCancel = "" - root.resumeAt = 0 - if (action === "resume") { - root.runControl(["systemctl", "--user", "start", "onedrive.service"], 1) - } else if (action === "pause") { - if (root.running || root.active || root.activeState === "activating") { - root.runControl(["systemctl", "--user", "stop", "onedrive.service"], 0) - } else if (root._pauseMinutes > 0) { - var minutes = root._pauseMinutes - root._pauseMinutes = 0 - root.scheduleResume(minutes) - } else { - root.refresh(false) - } - } - } + // Slots are spread across the interval, so three accounts at the default + // setting start about ten seconds apart and each is still polled about every + // thirty seconds. + Timer { + id: pollScheduler + interval: Math.max(1000, Math.round(root.refreshIntervalSec * 1000 / Math.max(1, root.accountCount))) + repeat: true + running: true + triggeredOnStart: true + onTriggered: root.pollNextAccount() } - Process { - id: scheduleTimerProcess - running: false - command: [] - stdout: StdioCollector { - id: timerStdout - waitForEnd: true - onStreamFinished: root._timerOutput = text - } - stderr: StdioCollector { - id: timerStderr - waitForEnd: true - onStreamFinished: root._timerError = text - } - onExited: function(exitCode) { - var stdout = String(timerStdout.text || root._timerOutput || "") - var stderr = String(timerStderr.text || root._timerError || "") - if (exitCode !== 0) { - root.lastError = root.elideStatus(stderr || stdout || "Could not schedule OneDrive resume") - root.actionStatus = "Timed pause failed; resuming syncing…" - root._scheduleRecovery = true - root.runControl(["systemctl", "--user", "start", "onedrive.service"], 1) - } else { - root.lastError = "" - root.actionStatus = "Timed pause scheduled" - actionStatusTimer.restart() - root.refresh(false) - } + // The old widget ran a dedicated two-second ramp alongside the poll timer, so + // a service coming up at login was noticed within ~2s. Reordering slots does + // not reproduce that -- with one account the slot IS the refresh interval -- + // so the fast ramp is a timer of its own again, running only until every + // account has reported. + Timer { + id: startupRamp + property int ticks: 0 + interval: 2000 + repeat: true + // Until every account has been polled once -- not until one succeeds, which + // would leave the rest without ramp coverage, and not forever, which is what + // an always-failing helper would otherwise get. The old widget capped its + // ramp at 15 ticks; so does this. + running: root.accountCount > 0 && !root.allAttempted && ticks < 15 * Math.max(1, root.accountCount) + onTriggered: { + ticks += 1 + root.pollNextAccount() } } - Process { - id: controlProcess - running: false - command: [] - stdout: StdioCollector { - id: controlStdout - waitForEnd: true - onStreamFinished: root._controlOutput = text - } - stderr: StdioCollector { - id: controlStderr - waitForEnd: true - onStreamFinished: root._controlError = text - } - onExited: function(exitCode) { - var desired = root._controlDesired - root._controlDesired = -1 - var stdout = String(controlStdout.text || root._controlOutput || "") - var stderr = String(controlStderr.text || root._controlError || "") - if (exitCode !== 0) { - root._desired = -1 - root._pauseMinutes = 0 - root._scheduleRecovery = false - root.lastError = root.elideStatus(stderr || stdout || "OneDrive service command failed") - } else { - root.lastError = "" - settleTimer.ticks = 0 - settleTimer.start() - if (desired === 0 && root._pauseMinutes > 0) { - var minutes = root._pauseMinutes - root._pauseMinutes = 0 - root.scheduleResume(minutes) - } else if (root._scheduleRecovery) { - root._scheduleRecovery = false - root.actionStatus = "Timed pause failed; syncing resumed" - actionStatusTimer.restart() - } - } - delayedRefresh.restart() - } + // Units can be enabled or removed while the widget runs. + Timer { + interval: 300000 + repeat: true + running: true + onTriggered: root.reloadAccounts() + } + + Component.onCompleted: { + // Seed the compatibility descriptor immediately so the panel has an account + // to bind to before the first discovery returns; discovery then reconciles + // it in place rather than replacing it. + descriptors.append(defaultDescriptor()) + selectedService = Commands.DEFAULT_SERVICE + reloadAccounts() } } diff --git a/TESTING.md b/TESTING.md index 7ea9968..b205d8a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -16,6 +16,48 @@ timer, journal, authentication state, local files, quota, sync status, cache reuse and state permissions — including that a routine refresh never runs a cloud query and that quota and sync-status failures never clear each other. +It also covers multi-account behaviour against a fake `systemctl`: that +`--list-accounts` finds every account from its own unit's `ExecStart` (ignoring +the bare template, stale `not-found` symlinks and masked units, and never +guessing a confdir from an instance name), that `--confdir` selects an account +and gives it its own cache *and* lock, that the confdir reaches the client as +exactly one argument and cannot introduce a second flag, that the helper never +creates a config directory it was only asked to read, that `--resume-unit` reads +that account's own timer and no other's, that the scheduler interleaves accounts +that have not reported rather than letting one monopolise the ramp, that a cloud +check waiting behind a routine poll still holds the shared slot, that an account +discovered by +`--list-accounts` always survives the `--service` gate, that a present-but- +unusable confdir is dropped rather than aliased onto the default account, and +that the no-flag JSON output keeps exactly its expected field set. + +The QML layer's pure logic is covered by node tests: `tests/Commands.test.js` +asserts every command vector as an exact array for both a plain service and a +template instance, `tests/Aggregate.test.js` covers all ten account states and +the worst-of-N rules, `tests/Discovery.test.js` covers reconciling discovery +results without churning delegates, and `tests/Notifications.test.js` covers +grouping a burst of events into one notification. + +## Multiple accounts, by hand + +With a plain `onedrive.service` only, the panel must be pixel-identical to the +single-account screenshots: no selector row, no extra spacing, hero title +`OneDrive`. + +With template instances (`onedrive@a`, `onedrive@b`, …): + +- each account appears once in the selector, named from its instance; +- switching accounts changes the hero, storage, activity and every control, + returns the scroll to the top, and leaves keyboard navigation working; +- pausing one account for 15 minutes leaves the others running, and its timer + resumes only that account; +- one account failing while another syncs shows the attention badge while the + cloud icon stays lit; +- removing the selected unit and waiting for rediscovery falls back to the + first remaining account rather than emptying the panel; +- restarting the shell with an active timed pause still shows the pending + resume for the right account. + ## In the shell Use an Omarchy Quattro VM with no host block device attached: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eddd5e0..da762e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,28 +5,99 @@ `Panel.qml`, which follows Omarchy's native popup ownership, keyboard, theme, and panel-switch contracts. -`Service.qml` is the asynchronous boundary between QML and the operating -system. Local polling, cloud checks, and systemd control each run in a -`Quickshell.Io.Process`; no command is constructed through a shell. -Timed pauses stop `onedrive.service` and schedule a fixed-name transient -`omaonedrive-resume.timer` through `systemd-run --user`. Replacing a preset or -resuming immediately first cancels that timer. If scheduling fails after the -service was stopped, the service is started again so a failed timer cannot -leave sync paused unexpectedly. +`Service.qml` is the coordinator and `Account.qml` is the asynchronous boundary +between QML and the operating system. One `Account` exists per discovered +account, each owning its own status, control and timer processes; local polling, +cloud checks, and systemd control each run in a `Quickshell.Io.Process`. No +command is constructed through a shell: every argv vector is built by +`Commands.js` from that account's own service, config directory and resume unit, +and `tests/Commands.test.js` asserts the exact arrays. + +`Service.qml` discovers accounts with the helper's `--list-accounts`, reconciles +the result by the stable service key so existing delegates and their +notification history survive, dispatches one local poll per scheduler slot +(`refreshIntervalSec` divided by the account count, so the steady-state poll rate +does not grow with the number of accounts), serialises explicit cloud checks +behind a semaphore of one -- a check deferred behind an account's own routine +poll still holds that slot -- and batches transition events into at most one +desktop notification per polling burst, whose window spans a full poll round so +accounts that fail together are reported together. Until every account has been +polled once, a separate two-second ramp polls faster, as the single-account +widget always did; it is capped, so a helper that always fails cannot spin on it. With no template instances, discovery yields the +plain `onedrive.service` and everything below behaves as it did when the widget +was single-account. + +Timed pauses stop that account's service and schedule its own transient resume +unit through `systemd-run --user` — `omaonedrive-resume` for the plain service, +`omaonedrive-resume@` for a template instance, so an in-flight timer +survives an upgrade and one account's pause never cancels another's. A few +instance names cannot yield a safe unit at all: one ending in `.timer` or +`.service` would make `systemd-run` derive the same unit as a different account, +and one long enough to overflow systemd's 255-byte limit cannot be scheduled. For +those, a timed pause degrades to an untimed one and says so, rather than arming a +timer that would collide. Replacing a +preset or resuming immediately first cancels only that account's timer. If +scheduling fails after the service was stopped, that same service is started +again so a failed timer cannot leave sync paused unexpectedly. `onedrive-status.py` reads: -- the effective `sync_dir` from `onedrive --display-config`; +- the account's `config` file, every `name = value` line of it, and the + effective `sync_dir` from that file and from `onedrive --display-config`; when + neither yields one, the default account falls back to `~/OneDrive` and every + other account reports no sync directory at all rather than borrowing that; - presence (never contents) of the CLI's `refresh_token` file; -- effective two-way, download-only, or upload-only mode from the CLI's - read-only `--display-config` output; -- `onedrive.service` load, enabled, active, failure, result, and main-process - exit states; -- the next activation of the transient timed-resume user timer, when present; +- effective two-way, download-only, or upload-only mode from the same two + sources; +- the client version, from `--display-config`, or from `onedrive --version` + when the account's directory does not exist; +- the `--service` unit's (default `onedrive.service`) load, enabled, active, + failure, result, and main-process exit states; +- the next activation of that account's transient timed-resume user timer, named + by `--resume-unit`, when present; without that flag only `onedrive.service` + has a well-known one and reads its legacy `omaonedrive-resume.timer`, while + every other account reports no resume time rather than borrowing it; - bounded user-journal history for sync-in-progress, live reconciliation phase, last-complete, and error state; - recent regular files below the configured sync directory, without following - symlinks. + symlinks; +- the names, `Description`, `LoadState` and `ExecStart` command line of the + `onedrive` and `onedrive@` user units, from which each account's + `--confdir` is read out of `argv[]` — never guessed from the instance name; +- systemd enablement symlinks, `*.wants/onedrive*.service` and + `*.requires/onedrive*.service`, under the user (`$XDG_CONFIG_HOME/systemd/user` + or `~/.config/systemd/user`, and `$XDG_RUNTIME_DIR/systemd/user` when that + variable is set) and system (`/run/systemd/user`, `/etc/systemd/user`, + `/usr/lib/systemd/user`) unit directories — or, when `OMAONEDRIVE_UNIT_ROOTS` + is set to a non-empty value, the colon-separated directories it names *instead* + of all of those — so an enabled-but-unloaded instance is still discovered; +- whether each candidate config directory exists, including every space-joined + prefix of a `--confdir` read out of an `ExecStart` — systemd renders argv + unquoted, so the longest prefix that is a real directory is taken as the + intended one, and prefixes are tried even through tokens that look like flags + because a directory may legitimately be named `My - Work`; +- this account's own cached presentation data from a previous run — quota, + remote status and the recent-file rows — out of its `status-cache.json`. + +`--confdir` selects which account's config directory is read, defaulting to +`$XDG_CONFIG_HOME/onedrive` or `~/.config/onedrive`; a value must be an absolute +path containing no C0 control character or DEL, and must not be an existing +non-directory. It reaches the CLI as a single argument. `--service` and +`--resume-unit` name that account's unit and the bare name of its transient +resume unit; neither may begin with `-`, because both are passed to `systemctl` +as positional arguments where such a value would be read as an option, and both +are capped at systemd's 255-byte unit-name limit counting the suffix. A +`--resume-unit` given with a trailing `.timer` is normalised rather than +refused, so a name whose instance legitimately ends that way still works. Given a +non-default `--service` with no `--confdir`, the helper reads that unit's own +config directory rather than describing the default account under another +account's name; a unit that cannot be resolved is reported as an unknown account +with no config directory, never as the default one. `--list-accounts` is a separate mode with its own output shape: +it prints a JSON array of the discovered accounts (`service`, `instance`, +`confdir`, `description`) and exits without building a status object or invoking +the OneDrive client at all, falling back to the single default account when +systemd is unavailable. The helper never creates a config directory it was only +asked to read. Routine status calls do not contact Microsoft. `--quota` invokes the CLI's fast `--display-quota` mode, while `--sync-status` invokes the potentially slow @@ -35,10 +106,32 @@ runs both with independent bounded timeouts. Quota and sync-status timestamps, errors, and successful results remain independent. Presentation data and recent-file rows are atomically cached under `$XDG_STATE_HOME/omarchy/io.github.salemsayed.omaonedrive`, or the standard -`~/.local/state` fallback. The directory is mode `0700`; the cache and lock are -mode `0600`. Failed or timed-out cloud queries retain the last successful result -and are reported separately from each other and from local service failures. A -file lock serializes multiple monitor/widget instances. +`~/.local/state` fallback. Each account gets its own directory there, keyed on +its service and canonical config directory, so accounts never read each other's +quota, recent files or remote status; the default account keeps the historical +top-level `status-cache.json` and `status.lock`. Every such directory is mode +`0700` and every cache and lock inside it is mode `0600`. Failed or timed-out +cloud queries retain the last successful result and are reported separately from +each other and from local service failures. Each account's own file lock +serializes multiple monitor/widget instances for that account, so one account's +cloud check cannot block another account's refresh. No refresh-token content, Microsoft response URL, access token, browser state, -or file content crosses the helper boundary. +or synced-file content crosses the helper boundary; the only additional data +`--list-accounts` emits is each unit's name, the instance parsed out of that +name, its systemd description, and its config-directory path. + +The bar shows one state for all accounts: each is classified into exactly one of +ten states and the worst wins, with the cloud icon lit while any account is +still working. An account that has not produced a first sample is excluded from the aggregate +rather than gating it: excluding it already stops default values flashing a +missing-client badge, while gating on all of them meant one permanently-failing +account froze the bar at `checking` forever, hiding a healthy account's real +state behind it. Only when nothing has reported is the aggregate `checking`, +with no badge drawn. The tooltip applies the same rule, so badge and tooltip +cannot disagree about which accounts are known. The tooltip is the account's own single line +when there is one account, and an attributed worst-first list when there are +several. `Model.js` holds the classification, aggregation, tooltip, badge and +notification-composition rules as pure functions, table-tested in +`tests/Aggregate.test.js`, `tests/Discovery.test.js` and +`tests/Notifications.test.js`. diff --git a/manifest.json b/manifest.json index 6280263..2b9048d 100644 --- a/manifest.json +++ b/manifest.json @@ -2,10 +2,10 @@ "schemaVersion": 1, "id": "io.github.salemsayed.omaonedrive", "name": "OmaOneDrive", - "version": "1.5.6", + "version": "1.6.0", "author": "Salem Sayed", "license": "MIT", - "description": "OneDrive status, timed pause controls, cloud storage, and recent sync activity in the Omarchy bar.", + "description": "OneDrive status, timed pause controls, cloud storage, and recent sync activity in the Omarchy bar, for one account or several.", "kinds": [ "bar-widget" ], @@ -14,7 +14,7 @@ }, "barWidget": { "displayName": "OmaOneDrive", - "description": "Control and temporarily pause the OneDrive CLI, check cloud status, and open recent local files.", + "description": "Control and temporarily pause the OneDrive CLI, check cloud status, and open recent local files. Discovers every configured account and shows the one that most needs attention.", "category": "Files", "allowMultiple": false, "defaultSection": "right", @@ -30,7 +30,7 @@ "type": "boolean", "label": "Desktop notifications", "defaultValue": true, - "description": "Notify when OneDrive needs attention, recovers, or cloud storage is almost full." + "description": "Notify when OneDrive needs attention, recovers, or cloud storage is almost full. Events from several accounts are grouped into one notification." }, { "key": "refreshIntervalSec", @@ -40,7 +40,7 @@ "max": 3600, "step": 10, "defaultValue": 30, - "description": "Reads the local service, journal, and cached cloud status. It does not contact Microsoft." + "description": "Reads the local service, journal, and cached cloud status. It does not contact Microsoft. With several accounts the interval is shared: each account is polled once per interval, staggered across it." }, { "key": "recentFileLimit", @@ -50,15 +50,18 @@ "max": 50, "step": 5, "defaultValue": 20, - "description": "Maximum number of recently modified local OneDrive files shown in the panel." + "description": "Maximum number of recently modified local OneDrive files shown in the panel, for the selected account." }, { "key": "panelStyle", "type": "enum", "label": "Panel layout", - "options": ["Full", "Compact"], + "options": [ + "Full", + "Compact" + ], "defaultValue": "Full", - "description": "Full shows storage and recent activity. Compact shows storage only, in a shorter panel." + "description": "Full shows storage and recent activity. Compact shows storage only, in a shorter panel. Both show the account selector when more than one account exists." } ] } diff --git a/onedrive-status.py b/onedrive-status.py index 1cf4b85..fc6404e 100755 --- a/onedrive-status.py +++ b/onedrive-status.py @@ -2,6 +2,7 @@ import argparse import fcntl +import hashlib import heapq import json import os @@ -16,7 +17,7 @@ PLUGIN_ID = "io.github.salemsayed.omaonedrive" DEFAULT_SERVICE = "onedrive.service" -RESUME_TIMER = "omaonedrive-resume.timer" +DEFAULT_RESUME_UNIT = "omaonedrive-resume" SCAN_CACHE_SECONDS = 120 MAX_SERVICE_EVENTS = 2 MAX_FILE_EVENTS = 5 @@ -43,11 +44,56 @@ def command_output(command, timeout=4): return completed.returncode, (completed.stdout + completed.stderr).strip() +def canonical_confdir(value): + # Collapses "." and ".." segments and any trailing slash so one account cannot + # key two different caches. Deliberately not realpath(): resolving symlinks + # would silently move an account whose config directory is a link. + return Path(os.path.normpath(str(value))) + + def default_confdir(): + # Canonical, like every other confdir we report. The widget compares the + # directory a reply says it read against the one discovery gave it, and refuses + # a mismatch -- so if discovery reported "/home/u/./config/onedrive" and a poll + # reported the normalised form, that account would be refused on every poll for + # ever, showing nothing and saying nothing. config_home = os.environ.get("XDG_CONFIG_HOME") if config_home: - return Path(config_home) / "onedrive" - return Path.home() / ".config" / "onedrive" + return canonical_confdir(Path(config_home) / "onedrive") + return canonical_confdir(Path.home() / ".config" / "onedrive") + + +def valid_confdir(value): + text = str(value) + if not text.startswith("/"): + return False + # Nothing is ever shell-interpolated, so ".." is not a threat here and a real + # directory reached through one must not be rejected; only characters that + # cannot appear in a path at all are refused. + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in text): + return False + path = canonical_confdir(text) + return not path.exists() or path.is_dir() + + +def account_state_dir(service, confdir): + # The default account keeps the historical directory, so its cache and lock + # paths are unchanged. Every other account gets its own directory, which + # isolates the lock as well as the cache: a 30s cloud check on one account + # must not block another account's ordinary poll. Keyed on the service AND + # the config directory, because two services may share one confdir. + base = state_dir() + if confdir is not None and str(service) == DEFAULT_SERVICE \ + and canonical_confdir(confdir) == canonical_confdir(default_confdir()): + return base + # os.fsencode, not str.encode: Linux paths are bytes, and a non-UTF-8 path + # arrives surrogate-escaped and would raise on a strict encode. + # An unknown confdir still gets a stable per-service key of its own. + confdir_key = os.fsencode(str(canonical_confdir(confdir))) if confdir is not None else b"" + digest = hashlib.sha256( + os.fsencode(str(service)) + b"\0" + confdir_key + ).hexdigest()[:16] + return base / "accounts" / digest def state_dir(): @@ -56,13 +102,39 @@ def state_dir(): return base / "omarchy" / PLUGIN_ID +# Every cache field that is used as a number. The cache is JSON we wrote, but it +# is also a file on disk that anything can edit, and a *valid* JSON document with +# a string where a number belongs used to raise ValueError before the code that +# would have repaired or replaced it -- so one bad byte made that account's +# helper fail on every poll, for ever, with no way out but deleting the file. +CACHE_INTS = ( + "scanLimit", "scanAt", "usedBytes", "quotaBytes", + "quotaCheckedTs", "syncStatusCheckedTs", "remoteCheckedTs", +) + + def load_cache(path): try: with path.open("r", encoding="utf-8") as handle: value = json.load(handle) - return value if isinstance(value, dict) else {} except (OSError, json.JSONDecodeError): return {} + if not isinstance(value, dict): + return {} + # Coerce here, once, rather than at every use site: a field that cannot be a + # number is treated as absent, and the next save writes it back correctly. + for key in CACHE_INTS: + if key not in value: + continue + try: + value[key] = int(value[key] or 0) + except (TypeError, ValueError, OverflowError): + # OverflowError is the one that is easy to miss: JSON's 1e999 parses to + # float infinity, which int() refuses. Like the others, it killed the + # helper for that account on every poll, for ever, before anything could + # repair the file. + value.pop(key, None) + return value def save_cache(path, value): @@ -101,7 +173,7 @@ def read_config_values(confdir): if not match: continue values[match.group(1)] = match.group(2).split("#", 1)[0].strip().strip('"') - except OSError: + except (OSError, UnicodeDecodeError): pass return values @@ -109,7 +181,16 @@ def read_config_values(confdir): def client_config(confdir, onedrive_path): values = read_config_values(confdir) client_version = "" - if onedrive_path: + # The client CREATES the tree when handed a --confdir that does not exist, which + # would be a config write the widget has no business making — and, before the + # argv re-join landed, could have created a truncated path like "/home/u/My". + if onedrive_path and not Path(confdir).is_dir(): + # --version takes no confdir, so the client version survives even when the + # account's directory does not exist yet. + exit_code, output = command_output([onedrive_path, "--version"], timeout=6) + if exit_code == 0: + client_version = output.strip().splitlines()[0].strip() if output.strip() else "" + if onedrive_path and Path(confdir).is_dir(): exit_code, output = command_output( [onedrive_path, "--confdir", str(confdir), "--display-config"], timeout=6, @@ -124,8 +205,15 @@ def client_config(confdir, onedrive_path): client_version = version_match.group(1).strip() sync_dir_value = values.get("sync_dir", "") - sync_dir = Path(os.path.expandvars(os.path.expanduser(sync_dir_value))) \ - if sync_dir_value else Path.home() / "OneDrive" + if sync_dir_value: + sync_dir = Path(os.path.expandvars(os.path.expanduser(sync_dir_value))) + elif canonical_confdir(confdir) == canonical_confdir(default_confdir()): + sync_dir = Path.home() / "OneDrive" + else: + # An account whose config could not be read has no known sync directory. + # Falling back to ~/OneDrive would list the DEFAULT account's files under + # this account's identity, and cache them there. + sync_dir = None download_only = parse_bool(values.get("download_only")) upload_only = parse_bool(values.get("upload_only")) if download_only and upload_only: @@ -147,19 +235,34 @@ def systemctl_value(arguments): return command_output(["systemctl", "--user", *arguments], timeout=4) -def service_state(service): +def unit_properties(unit, names): + """Parsed `systemctl show` properties for one unit, or None when the call failed. + + Multi-valued properties (ExecStart) keep every line; single-valued ones keep + the first, which is all systemd emits. + """ exit_code, output = systemctl_value([ "show", - service, - "--property=LoadState,ActiveState,SubState,UnitFileState,Result,ExecMainCode,ExecMainStatus", + unit, + "--property=" + ",".join(names), "--no-pager", ]) + if exit_code != 0: + return None properties = {} - if exit_code == 0: - for line in output.splitlines(): - key, separator, value = line.partition("=") - if separator: - properties[key] = value.strip() + for line in output.splitlines(): + key, separator, value = line.partition("=") + if separator: + properties.setdefault(key, []).append(value.strip()) + return properties + + +def service_state(service): + properties = unit_properties( + service, + ["LoadState", "ActiveState", "SubState", "UnitFileState", "Result", "ExecMainCode", "ExecMainStatus"], + ) or {} + properties = {key: values[0] for key, values in properties.items()} load_state = properties.get("LoadState", "") active_state = properties.get("ActiveState", "") unit_file_state = properties.get("UnitFileState", "") @@ -182,10 +285,10 @@ def service_state(service): } -def resume_timer_state(): +def resume_timer_state(unit): exit_code, output = systemctl_value([ "list-timers", - RESUME_TIMER, + unit + ".timer", "--all", "--output=json", "--no-pager", @@ -205,6 +308,274 @@ def resume_timer_state(): return next_usec // 1_000_000 if next_usec > 0 else 0 +# Unit names the helper is willing to hand back to itself through --service or +# --resume-unit. Discovery and the --service gate MUST accept the same set, or an +# account can be discoverable and permanently unusable, so both patterns are +# derived from one class. Backslash and colon are here because systemd-escape +# produces them (e.g. "onedrive@team\x20space.service"); "/" is not, so +# "../bad.service" is still refused. The first character may not be "-": these +# names are passed to systemctl as positional arguments, and "-Mguest.timer" or +# "-Hsomewhere.timer" would be read as its --machine/--host OPTIONS instead. +UNIT_NAME = r"[A-Za-z0-9_.@:\\][A-Za-z0-9_.@:\\-]*" +UNIT_NAME_PATTERN = re.compile(UNIT_NAME) +SERVICE_NAME_PATTERN = re.compile(UNIT_NAME + r"\.service") + +# systemd's own ceiling for a unit name, which the ".timer" suffix counts against. +MAX_UNIT_NAME_LENGTH = 255 + + +def valid_unit_name(value, suffix): + # One rule for every unit name the helper hands to systemctl, so the --service + # and --resume-unit gates cannot drift apart. + if len(value) + len(suffix) > MAX_UNIT_NAME_LENGTH: + return False + return UNIT_NAME_PATTERN.fullmatch(value) is not None + + +def resume_unit_name(value): + # The caller passes a BARE name and resume_timer_state appends ".timer", so a + # value that already carries one would query ".timer.timer". Strip one + # rather than refusing: the same spelling is legitimate when an instance ends + # in ".timer", and refusing would abort the status call entirely. + return value[: -len(".timer")] if value.endswith(".timer") else value + + +def valid_resume_unit(value): + return valid_unit_name(resume_unit_name(value), ".timer") + +# "onedrive.service" or "onedrive@.service"; the bare template +# "onedrive@.service" deliberately does not match — it is not an account. +ONEDRIVE_UNIT_PATTERN = re.compile(r"onedrive(?:@(?P[A-Za-z0-9_.:\\-]+))?\.service") + +# systemd renders each ExecStart entry as a brace-delimited record of +# " ; "-separated key=value fields: +# { path=/usr/bin/onedrive ; argv[]=/usr/bin/onedrive --monitor --confdir=/x ; ... } +# Splitting the record into fields (rather than searching the whole string for +# "argv[]=") is what keeps a path= value containing the literal text "argv[]=" +# from being read as the argv. +# The delimiters are "{ " and " }" (with the spaces), so a brace inside an argv +# value cannot be mistaken for a record boundary. +EXEC_RECORD_PATTERN = re.compile(r"\{ (?P.*?) \}") + + +def exec_start_fields(body): + fields = {} + for part in body.split(" ; "): + key, separator, value = part.partition("=") + if separator: + fields.setdefault(key.strip(), value.strip()) + return fields + + +def join_confdir_tokens(head, rest): + # systemd joins argv with literal spaces and does not quote, so a confdir + # containing a space arrives split across tokens and is indistinguishable from + # separate arguments. Re-join greedily and let the filesystem arbitrate: the + # longest prefix that is an existing directory wins. A path that does not + # exist stays as the first token, which is the honest reading. + if head[:1] in ("'", '"'): + quote = head[0] + candidate = head[1:] + if candidate.endswith(quote): + return candidate[:-1] + for token in rest: + candidate += " " + token + if token.endswith(quote): + return candidate[:-1] + return candidate + # Every space-joined prefix is tried, including through tokens that look like + # flags: a directory may legitimately be named "My - Work", and only the + # filesystem can settle it. The longest one that exists wins; a following real + # flag simply never forms an existing path. + best = head if Path(head).is_dir() else "" + candidate = head + for token in rest: + candidate += " " + token + if Path(candidate).is_dir(): + best = candidate + return best or head + + +def confdir_from_argv(argv): + """The --confdir in one argv, or None when the argv carries none at all. + + None and "" are different answers: None means this unit simply does not pass + --confdir (so the client default applies), while a returned string may still + be invalid. Collapsing the two is what let an unusable confdir silently + masquerade as the default account. + """ + tokens = str(argv).split(" ") + for index, token in enumerate(tokens): + if token.startswith("--confdir="): + return join_confdir_tokens(token[len("--confdir="):], tokens[index + 1:]) + if token == "--confdir" and index + 1 < len(tokens): + return join_confdir_tokens(tokens[index + 1], tokens[index + 2:]) + return None + + +def confdir_from_exec_start(value): + """The account's confdir; None when no --confdir applies; "" when unreadable. + + Three answers, because the caller must treat them differently: None means the + client default genuinely applies, while "" means the ExecStart could not be + parsed and the unit must be dropped rather than aliased onto the default + account. + """ + if not value.strip(): + return None + # A unit may carry several ExecStart lines, only one of which is the OneDrive + # client; a preparatory command's --confdir must not be mistaken for it. The + # client's own record is authoritative once found, INCLUDING when it carries no + # --confdir at all -- otherwise a preparatory command's flag wins by default. + fallback = None + parsed = False + for match in EXEC_RECORD_PATTERN.finditer(value): + argv = exec_start_fields(match.group("body")).get("argv[]", "") + tokens = [token for token in argv.split(" ") if token] + if not tokens: + continue + parsed = True + confdir = confdir_from_argv(argv) + if "onedrive" in os.path.basename(tokens[0]): + return confdir + if fallback is None and confdir is not None: + fallback = confdir + return fallback if parsed else "" + + +def unit_search_dirs(): + # Overridable so the test suite can enumerate a sandboxed tree instead of the + # host's real unit directories; also useful in a container. + override = os.environ.get("OMAONEDRIVE_UNIT_ROOTS") + if override: + return [Path(part) for part in override.split(":") if part] + config_home = os.environ.get("XDG_CONFIG_HOME") + base = Path(config_home) if config_home else Path.home() / ".config" + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") + directories = [base / "systemd" / "user"] + # Runtime enablement ("systemctl --user enable --runtime") writes here, and is + # invisible to both list-units and the persistent search path. + if runtime_dir: + directories.append(Path(runtime_dir) / "systemd" / "user") + directories.extend([ + Path("/run/systemd/user"), + Path("/etc/systemd/user"), + # /lib/systemd/user is a usrmerge symlink to this one; globbing both is a + # pure duplicate that the name dedupe would only have to undo. + Path("/usr/lib/systemd/user"), + ]) + return directories + + +def wants_unit_names(): + # "list-units" only reports units systemd currently has loaded, so an instance + # that is enabled but has never been started (or was garbage-collected while + # inactive) is invisible there. Its enablement symlink is not, and + # "list-unit-files" never expands template instances, so the symlinks are the + # only way to see it. + names = [] + for directory in unit_search_dirs(): + for pattern in ("*.wants/onedrive*.service", "*.requires/onedrive*.service"): + try: + for link in directory.glob(pattern): + names.append(link.name) + except OSError: + continue + return names + + +def onedrive_unit_names(): + names = [] + exit_code, output = systemctl_value([ + "list-units", + "onedrive*", + "--all", + "--no-legend", + "--plain", + "--no-pager", + ]) + if exit_code == 0: + for line in output.splitlines(): + fields = line.split() + if fields: + names.append(fields[0]) + # Names swept off the filesystem are raw symlink names, never validated by + # systemd, so the pattern filter below is what keeps an unusable name out of + # the payload — it is a gate, not a tidy-up. + names.extend(wants_unit_names()) + return list(dict.fromkeys( + name for name in names if ONEDRIVE_UNIT_PATTERN.fullmatch(name) + )) + + +def default_account(): + return { + "service": DEFAULT_SERVICE, + "instance": "", + "confdir": str(default_confdir()), + "description": "OneDrive", + } + + +def account_entry(unit): + match = ONEDRIVE_UNIT_PATTERN.fullmatch(unit) + if match is None: + # Reachable from build_status: --service accepts any unit name, not only + # ours. Not an account, so there is nothing to describe. + return None + properties = unit_properties(unit, ["ExecStart", "Description", "LoadState"]) + if properties is None: + return None + load_state = (properties.get("LoadState") or [""])[0] + # An allowlist, not a denylist. not-found is a stale enablement symlink and + # masked is a unit deliberately turned off, but "error", "stub" and + # "bad-setting" leave ExecStart empty in exactly the same way -- and an empty + # ExecStart then read as "this unit passes no --confdir, so the client default + # applies", aliasing a template instance onto the DEFAULT account's token, + # cache and sync directory. That is the identity leak the masked case was + # already there to prevent. + if load_state != "loaded": + return None + # All ExecStart lines are considered together: systemd emits one line per + # record, and the "prefer the record whose argv[0] is onedrive" rule only + # means anything when it can see every record at once. + exec_start = [line for line in properties.get("ExecStart", []) if line.strip()] + if not exec_start: + # Loaded, but with nothing to run: `systemctl show` answered with no + # properties, or the unit has no ExecStart. Either way we have learned + # nothing about where this account lives, and answering with the default + # would be a claim about identity we cannot support. + return None + confdir = confdir_from_exec_start("\n".join(exec_start)) + if confdir is None: + # The unit really does pass no --confdir, so the client default applies. + confdir = str(default_confdir()) + elif valid_confdir(confdir): + confdir = str(canonical_confdir(confdir)) + else: + # Present but unusable. Reporting the default here would alias this unit onto + # the default account's token, cache and sync directory. + return None + return { + "service": unit, + "instance": match.group("instance") or "", + "confdir": confdir, + "description": (properties.get("Description") or [""])[0] or "OneDrive", + } + + +def discover_accounts(): + accounts = [ + entry for entry in (account_entry(unit) for unit in onedrive_unit_names()) if entry + ] + if not accounts: + # No systemd, no systemctl, or nothing enabled: degrade to the single + # default account so callers still get a usable list. + return [default_account()] + accounts.sort(key=lambda row: row["instance"]) + return accounts + + TRANSFER_SKIP_PREFIXES = ("changes", "differences", "new items", "items", "advertised") # Multi-line CLI error blocks: "ERROR: ..." then indented detail lines. @@ -577,12 +948,35 @@ def status_text(installed, authenticated, service, journal, resume_at=0): def build_status(args): onedrive_path = shutil.which("onedrive") - confdir = default_confdir() - config = client_config(confdir, onedrive_path) + if args.confdir: + confdir = canonical_confdir(args.confdir) + elif args.service != DEFAULT_SERVICE: + # Asked about another account but told nothing about where it lives: read the + # confdir out of that unit, exactly as discovery does. Reporting the default + # account's token and files under this account's name would be a lie. + entry = account_entry(args.service) + # None, not the default: an unresolvable unit is an unknown account, and + # answering with the default account's data would be a lie about identity. + confdir = canonical_confdir(entry["confdir"]) if entry else None + else: + confdir = canonical_confdir(default_confdir()) + config = client_config(confdir, onedrive_path) if confdir else { + "syncDir": None, + "syncMode": "Two-way", + "clientVersion": "", + } sync_dir = config["syncDir"] - authenticated = (confdir / "refresh_token").is_file() + authenticated = confdir is not None and (confdir / "refresh_token").is_file() service = service_state(args.service) - resume_at = resume_timer_state() + # Each account schedules its own transient resume unit, so the caller names it. + # Without the flag only the plain service has a well-known one -- + # DEFAULT_RESUME_UNIT starts onedrive.service, so its pending time belongs to + # that SERVICE whichever config directory was named -- and every other account + # reports no resume time rather than borrowing that one. + resume_unit = args.resume_unit or ( + DEFAULT_RESUME_UNIT if args.service == DEFAULT_SERVICE else "" + ) + resume_at = resume_timer_state(resume_unit_name(resume_unit)) if resume_unit else 0 journal = journal_state(args.service) if service["serviceAvailable"] else { "syncing": False, "lastSyncTs": 0, @@ -595,9 +989,17 @@ def build_status(args): "syncStage": "", } - directory = state_dir() + directory = account_state_dir(args.service, confdir) directory.mkdir(parents=True, exist_ok=True, mode=0o700) + # Every level, not just the leaf: mkdir applies its mode to the leaf alone, so + # a run for a non-default account would otherwise leave the plugin state root + # world-traversable on a machine where the default account never polls. + os.chmod(state_dir(), 0o700) os.chmod(directory, 0o700) + if directory != state_dir(): + os.chmod(directory.parent, 0o700) + # Both the lock and the cache live in this directory, so a 30s cloud check on + # one account cannot block another account's ordinary poll. lock_path = directory / "status.lock" cache_path = directory / "status-cache.json" with lock_path.open("a+", encoding="utf-8") as lock: @@ -611,10 +1013,11 @@ def build_status(args): cached_sync_dir = str(cache.get("scanSyncDir", "")) cached_limit = int(cache.get("scanLimit", 0) or 0) scan_at = int(cache.get("scanAt", 0) or 0) - if cached_sync_dir != str(sync_dir) or cached_limit != args.limit or now - scan_at >= SCAN_CACHE_SECONDS: - cache["files"] = scan_recent(sync_dir, args.limit) + sync_dir_text = str(sync_dir) if sync_dir else "" + if cached_sync_dir != sync_dir_text or cached_limit != args.limit or now - scan_at >= SCAN_CACHE_SECONDS: + cache["files"] = scan_recent(sync_dir, args.limit) if sync_dir else [] cache["scanAt"] = now - cache["scanSyncDir"] = str(sync_dir) + cache["scanSyncDir"] = sync_dir_text cache["scanLimit"] = args.limit check_quota = args.remote or args.quota @@ -681,7 +1084,13 @@ def build_status(args): "syncStage": journal["syncStage"] if service["running"] else "", "statusText": status_text(onedrive_path is not None, authenticated, service, journal, resume_at), "resumeAt": resume_at, - "syncDir": str(sync_dir), + # The identity stamp. The widget cannot otherwise tell whether a reply + # describes the account it now believes this unit to be: the startup poll + # runs before discovery has read the unit's ExecStart, so it uses the + # client's default directory, and if the unit overrides it that reply belongs + # to a different account entirely. + "confdir": str(confdir) if confdir else "", + "syncDir": str(sync_dir) if sync_dir else "", "syncMode": config["syncMode"], "clientVersion": config["clientVersion"], "lastSyncTs": journal["lastSyncTs"], @@ -709,10 +1118,31 @@ def main(): parser.add_argument("--remote", action="store_true", help="query both quota and full-drive sync status") parser.add_argument("--limit", type=int, default=20, help="number of recent local files") parser.add_argument("--service", default=DEFAULT_SERVICE, help="systemd user service name") + parser.add_argument("--confdir", default=None, help="OneDrive config directory for this account") + parser.add_argument( + "--resume-unit", + default=None, + help="bare name of this account's transient resume unit, without a suffix; " + "when omitted only " + DEFAULT_SERVICE + " reads its legacy " + + DEFAULT_RESUME_UNIT + " timer and every other account reports none", + ) + parser.add_argument( + "--list-accounts", + action="store_true", + help="print the accounts configured on this machine as JSON and exit", + ) args = parser.parse_args() args.limit = max(5, min(50, args.limit)) - if not re.fullmatch(r"[A-Za-z0-9_.@-]+\.service", args.service): + if not SERVICE_NAME_PATTERN.fullmatch(args.service) \ + or not valid_unit_name(args.service[: -len(".service")], ".service"): parser.error("invalid service name") + if args.confdir is not None and not valid_confdir(args.confdir): + parser.error("invalid confdir") + if args.resume_unit is not None and not valid_resume_unit(args.resume_unit): + parser.error("invalid resume unit") + if args.list_accounts: + print(json.dumps(discover_accounts(), separators=(",", ":"))) + return print(json.dumps(build_status(args), separators=(",", ":"))) diff --git a/tests/Aggregate.test.js b/tests/Aggregate.test.js new file mode 100644 index 0000000..cdfc953 --- /dev/null +++ b/tests/Aggregate.test.js @@ -0,0 +1,633 @@ +const assert = require("node:assert") +const test = require("node:test") + +const Model = require("../Model.js") + +// A healthy, initialized account. Each case below overrides only what it means +// to test, so a case cannot pass by accident of an unrelated default. +function account(overrides) { + return Object.assign({ + initialized: true, + service: "onedrive@x.service", + instance: "x", + description: "OneDrive sync (x account)", + installed: true, + authenticated: true, + serviceAvailable: true, + running: true, + activeState: "active", + serviceFailed: false, + resyncRequired: false, + reauthRequired: false, + syncing: false, + syncMode: "Two-way", + statusText: "Monitoring", + lastSyncTs: 0 + }, overrides || {}) +} + +test("every state in the total order is reachable and ranked worst-first", () => { + const cases = [ + ["resync", { resyncRequired: true, serviceFailed: true }, 1], + ["reauth", { reauthRequired: true }, 2], + ["failed", { serviceFailed: true }, 3], + ["missing", { installed: false }, 4], + ["login", { authenticated: false }, 5], + ["unavailable", { serviceAvailable: false }, 6], + ["paused", { running: false, activeState: "inactive" }, 7], + ["starting", { activeState: "activating", running: false }, 8], + ["syncing", { syncing: true }, 9], + ["healthy", {}, 10] + ] + for (const [kind, overrides, rank] of cases) { + const state = Model.accountState(account(overrides)) + assert.equal(state.kind, kind, `expected ${kind}, got ${state.kind}`) + assert.equal(state.rank, rank) + } + // Ranks are unique and dense, so "worst" is always well defined. + const ranks = cases.map(([, , rank]) => rank) + assert.deepEqual(ranks, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) +}) + +test("the order holds when several conditions are true at once", () => { + // The table above uses one condition per case, which cannot detect a swapped + // pair. These fixtures set BOTH conditions of each adjacent pair, so the + // winner is decided by precedence alone. + const pairs = [ + [{ resyncRequired: true, reauthRequired: true }, "resync"], + [{ reauthRequired: true, serviceFailed: true }, "reauth"], + [{ serviceFailed: true, installed: false }, "failed"], + [{ installed: false, authenticated: false }, "missing"], + [{ authenticated: false, serviceAvailable: false }, "login"], + [{ serviceAvailable: false, running: false, activeState: "inactive" }, "unavailable"], + [{ running: false, activeState: "inactive", syncing: true }, "paused"], + [{ activeState: "activating", running: false, syncing: true }, "starting"] + ] + for (const [overrides, expected] of pairs) { + assert.equal(Model.accountStateKind(account(overrides)), expected, JSON.stringify(overrides)) + } +}) + +test("a just-pressed pause beats a stale syncing flag", () => { + // `active` folds in the optimistic desired state. Without it the badge lagged + // a poll in both directions, and a syncing flag left over from before the + // pause outranked the pause itself. + assert.equal(Model.accountStateKind(account({ running: true, active: false })), "paused") + assert.equal(Model.accountStateKind(account({ running: true, active: false, syncing: true })), "paused") + // ...and resuming is equally immediate. + assert.equal(Model.accountStateKind(account({ running: false, active: true, activeState: "inactive" })), "healthy") +}) + +test("a required resync outranks the failure it also sets", () => { + // Exit 126 sets both; "Resync required" is the actionable half. + assert.equal(Model.accountStateKind(account({ resyncRequired: true, serviceFailed: true })), "resync") +}) + +test("the worst account wins, and the cloud stays lit while any account is active", () => { + // The design's concrete disagreement: Dragones reauth, Personal syncing, + // Tandera paused. + const accounts = [ + account({ instance: "dragones", reauthRequired: true, statusText: "Reauthentication required" }), + account({ instance: "personal", syncing: true, statusText: "Syncing" }), + account({ instance: "tandera", running: false, activeState: "inactive", statusText: "Sync paused" }) + ] + const summary = Model.aggregateAccounts(accounts) + assert.equal(summary.kind, "reauth") + assert.equal(summary.count, 3) + assert.equal(summary.worst.instance, "dragones") + assert.equal(summary.anyActive, true) +}) + +test("only a genuinely working account lights the icon", () => { + // Discriminating: nothing here is running, so the ONLY thing that can light + // the icon is the syncing account. Drop the syncing clause from + // aggregateAccounts and this fails. + // + // `active` is deliberately UNDEFINED here, not false: undefined means "no + // optimistic intent, work it out from the sample", which is the state an + // account is in between polls. Setting it to false would mean "the user just + // paused this", and the fixture would then be asserting that a pause leaves + // the icon lit -- see the next test. + const stopped = { running: false, active: undefined, activeState: "inactive" } + const syncingOnly = [ + account(Object.assign({ instance: "a", reauthRequired: true }, stopped)), + account(Object.assign({ instance: "b", syncing: true }, stopped)) + ] + assert.equal(Model.aggregateAccounts(syncingOnly).anyActive, true) + + const noneWorking = [ + account(Object.assign({ instance: "a", reauthRequired: true }, stopped)), + account(Object.assign({ instance: "b" }, stopped)) + ] + assert.equal(Model.aggregateAccounts(noneWorking).anyActive, false) + + // An account that is running but was just paused reports active:false, and + // the icon must dim immediately rather than waiting for the next poll. + const justPaused = [account({ instance: "a", running: true, active: false })] + assert.equal(Model.aggregateAccounts(justPaused).anyActive, false) +}) + +test("pausing during a transfer dims the icon at once", () => { + // The real post-click state: the last sample still says the account was + // uploading, and `active` says the user has just stopped it. The helper only + // ever sets `syncing` alongside `running`, so this leftover flag was the one + // thing that could still light the icon -- and it did, until a confirming + // poll landed a scheduler slot later. Pausing for a meeting looked like it + // had not taken. + const midTransfer = [account({ + instance: "work", running: true, active: false, syncing: true, + statusText: "Uploading report.docx" + })] + const summary = Model.aggregateAccounts(midTransfer) + assert.equal(summary.kind, "paused") + assert.equal(summary.anyActive, false, "the icon must dim") + assert.equal(Model.barState(summary).active, false) + assert.equal(Model.barState(summary).syncing, false) + + // ...while an account transferring that nobody has touched stays lit, even + // between samples that report it running. + const stillGoing = [account({ + instance: "work", running: false, active: undefined, + activeState: "inactive", syncing: true + })] + assert.equal(Model.aggregateAccounts(stillGoing).anyActive, true) +}) + +test("all accounts idle means the icon dims", () => { + const accounts = [ + account({ instance: "a", running: false, activeState: "inactive" }), + account({ instance: "b", running: false, activeState: "inactive" }) + ] + assert.equal(Model.aggregateAccounts(accounts).anyActive, false) +}) + +test("equal ranks resolve by discovery order, not by name", () => { + const accounts = [ + account({ instance: "zebra", reauthRequired: true }), + account({ instance: "alpha", reauthRequired: true }) + ] + assert.equal(Model.aggregateAccounts(accounts).worst.instance, "zebra") +}) + +test("an uninitialized account never contributes a state", () => { + // Default property values would classify as "missing"; flashing that badge + // before the first poll is the failure this guards. But an account that has + // not reported must be EXCLUDED, not allowed to gate the whole aggregate -- + // see the freeze test below. + const accounts = [ + account({ instance: "a", reauthRequired: true }), + { initialized: false, instance: "b", installed: false, authenticated: false } + ] + const summary = Model.aggregateAccounts(accounts) + assert.notEqual(summary.kind, "missing") + // The account that HAS reported still drives the badge. + assert.equal(summary.kind, "reauth") + assert.equal(summary.worst.instance, "a") +}) + +test("one account that can never initialize does not freeze the bar", () => { + // A unit whose confdir is unreadable makes the helper exit non-zero on every + // poll, so that account's `initialized` is never set. Gating the aggregate on + // ALL accounts meant the bar sat at "checking" forever -- no badge, undimmed + // icon -- while another account was resync-required and invisible. + const accounts = [ + { initialized: false, instance: "broken" }, + account({ instance: "ok", resyncRequired: true }) + ] + const summary = Model.aggregateAccounts(accounts) + assert.equal(summary.kind, "resync") + assert.equal(summary.initialized, true) + // ...and only when NOTHING has reported is it still checking. + assert.equal(Model.aggregateAccounts([{ initialized: false }]).kind, "checking") +}) + +test("an empty account list is checking, not missing", () => { + const summary = Model.aggregateAccounts([]) + assert.equal(summary.kind, "checking") + assert.equal(summary.count, 0) +}) + +test("one account keeps exactly today's single-line tooltip", () => { + const only = account({ instance: "", statusText: "Monitoring", lastSyncTs: 0 }) + assert.equal(Model.aggregateTooltip([only], Date.now()), Model.tooltip(only, Date.now())) + assert.ok(!Model.aggregateTooltip([only], Date.now()).includes("\n")) +}) + +test("many accounts get an attributed, worst-first tooltip", () => { + const now = Date.now() + const accounts = [ + account({ instance: "dragones", reauthRequired: true, statusText: "Reauthentication required" }), + account({ instance: "personal", syncing: true, statusText: "Syncing" }), + account({ instance: "tandera", running: false, activeState: "inactive", statusText: "Sync paused" }) + ] + const lines = Model.aggregateTooltip(accounts, now).split("\n") + assert.equal(lines[0], "OneDrive · 3 accounts") + // Worst first: reauth (2) then paused (7) then syncing (9). NOTE: the design + // doc's illustrative example lists Personal before Tandera, which is discovery + // order, not the worst-first rule the same section states. The rule wins -- + // an account needing attention must not sort below one that is merely idle. + assert.ok(lines[1].startsWith("Dragones: Reauthentication required")) + assert.ok(lines[2].startsWith("Tandera: Sync paused")) + assert.ok(lines[3].startsWith("Personal: Syncing")) + assert.equal(lines.length, 4) +}) + +test("a large installation cannot grow an unbounded tooltip", () => { + const accounts = [] + for (let index = 0; index < 9; index++) accounts.push(account({ instance: "acct" + index })) + const lines = Model.aggregateTooltip(accounts, Date.now()).split("\n") + assert.equal(lines.length, 1 + 5 + 1) + assert.equal(lines[lines.length - 1], "+4 more") +}) + +test("the cap keeps the accounts that need a human, not an arbitrary five", () => { + // Nine healthy accounts plus one that needs reauth, with the unhealthy one + // LAST in discovery order. A cap that simply took the first five in discovery + // order -- or the last five -- would hide it behind "+N more". + const accounts = [] + for (let index = 0; index < 9; index++) accounts.push(account({ instance: "ok" + index })) + accounts.push(account({ instance: "broken", reauthRequired: true, + statusText: "Reauthentication required" })) + const lines = Model.aggregateTooltip(accounts, Date.now()).split("\n") + assert.ok(lines[1].startsWith("Broken: Reauthentication required"), lines.join(" | ")) + assert.equal(lines.length, 1 + 5 + 1) +}) + +test("the tooltip is checking only while nothing has reported", () => { + const nothing = [{ initialized: false, instance: "a" }, { initialized: false, instance: "b" }] + assert.equal(Model.aggregateTooltip(nothing, Date.now()), "Checking 2 OneDrive accounts…") + + // Once any account has reported, the tooltip says what it knows rather than + // hiding it behind a permanent "checking". + const partial = [account({ instance: "a", statusText: "Monitoring" }), + { initialized: false, instance: "b" }] + const text = Model.aggregateTooltip(partial, Date.now()) + assert.ok(text.startsWith("OneDrive · 2 accounts"), text) + assert.ok(text.includes("A: Monitoring"), text) + // The un-polled account must NOT be listed. Its default values classify as + // "missing", which would sort it to the TOP of a worst-first list -- so the + // tooltip would lead with "OneDrive CLI is not installed" while the badge, + // which excludes it, showed nothing at all. + assert.ok(!text.includes("B:"), text) + assert.ok(!text.includes("not installed"), text) + assert.ok(text.includes("Checking 1 more"), text) +}) + +test("every state maps onto the existing badge vocabulary", () => { + // The badge set is deliberately smaller than the state set: at eight pixels a + // badge carries a category, and the tooltip carries the detail. + assert.equal(Model.badgeKind("resync"), "attention") + assert.equal(Model.badgeKind("reauth"), "attention") + assert.equal(Model.badgeKind("failed"), "attention") + assert.equal(Model.badgeKind("missing"), "missing") + assert.equal(Model.badgeKind("unavailable"), "missing") + assert.equal(Model.badgeKind("login"), "login") + assert.equal(Model.badgeKind("paused"), "paused") + assert.equal(Model.badgeKind("starting"), "syncing") + assert.equal(Model.badgeKind("syncing"), "syncing") + // Healthy shows no badge, and neither does the pre-first-poll state -- that + // is the whole point of having a checking state. + assert.equal(Model.badgeKind("healthy"), "") + assert.equal(Model.badgeKind("checking"), "") +}) + +test("no state is left without a badge decision", () => { + const states = ["resync", "reauth", "failed", "missing", "login", "unavailable", + "paused", "starting", "syncing", "healthy", "checking"] + for (const kind of states) { + assert.equal(typeof Model.badgeKind(kind), "string", kind) + } +}) + +// origin/main:BarWidget.qml's flat ternary, transcribed. The single-account +// badge is compared against THIS over the whole flag space rather than against a +// hand-picked list -- a list can only ever be a partial oracle, and the earlier +// one omitted every combination where the two actually diverge. +function legacyBadgeKind(a) { + const active = a.active !== undefined ? a.active : (a.running || a.activeState === "activating") + const attention = a.serviceFailed || a.resyncRequired || a.reauthRequired + if (!a.installed) return "missing" + if (!a.authenticated) return "login" + if (attention) return "attention" + if (a.syncing || a.activeState === "activating") return "syncing" + if (!active) return "paused" + return "" +} + +test("the single-account badge is compared against the old ternary exhaustively", () => { + const flags = ["installed", "authenticated", "serviceAvailable", "running", + "serviceFailed", "resyncRequired", "reauthRequired", "syncing"] + const divergences = [] + for (let mask = 0; mask < (1 << flags.length); mask++) { + for (const activeState of ["active", "activating", "inactive"]) { + const overrides = { activeState: activeState } + flags.forEach((flag, bit) => { overrides[flag] = Boolean(mask & (1 << bit)) }) + overrides.active = overrides.running || activeState === "activating" + const mine = Model.badgeKind(Model.aggregateAccounts([account(overrides)]).kind) + const old = legacyBadgeKind(overrides) + if (mine !== old) divergences.push({ overrides, mine, old }) + } + } + + // Every divergence must be one of the deliberate ones, named here with its + // reason. An unlisted divergence fails, which is the point. + const allowed = [ + // The old ternary could not express "installed and signed in but the unit is + // not loaded"; it called that paused. `unavailable` is a distinct state and + // maps to the missing glyph, with the tooltip distinguishing them. + d => d.overrides.installed && d.overrides.authenticated && !d.overrides.serviceAvailable, + // The old ternary checked !authenticated before the attention flags, so a + // failing service on a signed-out account showed a login key. Attention + // outranks it now: a failure is the more actionable of the two. + d => !d.overrides.authenticated && (d.overrides.serviceFailed || d.overrides.resyncRequired + || d.overrides.reauthRequired), + // Likewise for a missing client with a failing unit. + d => !d.overrides.installed && (d.overrides.serviceFailed || d.overrides.resyncRequired + || d.overrides.reauthRequired), + // The old ternary let a stale `syncing` flag outrank a stopped service, so a + // just-pressed Pause kept a pulsing sync badge until the next poll. Pause + // wins now, which is what makes the button feel immediate. + d => d.overrides.syncing && !d.overrides.active && d.mine === "paused" && d.old === "syncing" + ] + const unexplained = divergences.filter(d => !allowed.some(rule => rule(d))) + assert.deepEqual(unexplained, [], + "undeclared badge divergence from origin/main: " + JSON.stringify(unexplained.slice(0, 3), null, 1)) +}) + +// --- which account the panel opens on ---------------------------------------- +// +// The badge is worst-of-N; every control in the panel acts on the SELECTED +// account. Those were unrelated, so the bar could show "reauthentication +// required" for Work while the panel opened on a healthy Personal -- and the +// P key, right-click Storage and the IPC controls all acted on Personal. + +// `summary` is the aggregate as the coordinator has it. +function summaryOf(kind, worstService) { + return { kind: kind, worst: worstService ? { service: worstService } : null } +} + +test("opening the panel moves to the account the badge is blaming", () => { + for (const kind of ["reauth", "resync", "failed", "missing", "login", "unavailable"]) { + assert.equal( + Model.openSelection(summaryOf(kind, "onedrive@work.service"), "healthy"), + "onedrive@work.service", kind) + } + assert.equal( + Model.openSelection(summaryOf("resync", "onedrive@work.service"), "syncing"), + "onedrive@work.service") + assert.equal( + Model.openSelection(summaryOf("failed", "onedrive@work.service"), "paused"), + "onedrive@work.service") +}) + +test("a selection the user made for a reason is not stolen", () => { + // Having opened Work to deal with its reauth, the user must not be bounced to + // Personal the moment Personal fails too. + assert.equal( + Model.openSelection(summaryOf("resync", "onedrive@personal.service"), "reauth"), "") + assert.equal( + Model.openSelection(summaryOf("failed", "onedrive@personal.service"), "login"), "") +}) + +test("clicking a spinning bar reaches the account that is actually transferring", () => { + // Under worst-first a spinning badge means the WORST account is the one + // transferring (a paused or broken account would outrank it), so the click + // goes there rather than to whatever was selected last -- often the cold-boot + // default. But never away from an account the user is already watching sync. + const spinning = summaryOf("syncing", "onedrive@personal.service") + assert.equal(Model.openSelection(spinning, "paused"), "onedrive@personal.service") + assert.equal(Model.openSelection(spinning, "healthy"), "onedrive@personal.service") + assert.equal(Model.openSelection(spinning, "syncing"), "") + assert.equal(Model.openSelection(spinning, "starting"), "") + const starting = summaryOf("starting", "onedrive@personal.service") + assert.equal(Model.openSelection(starting, "healthy"), "onedrive@personal.service") + // A paused fleet does not spin, and does not move the selection. + assert.equal(Model.openSelection(summaryOf("paused", "onedrive@work.service"), "healthy"), "") +}) + +test("a healthy fleet never moves the selection", () => { + for (const kind of ["healthy", "paused", "checking", ""]) { + assert.equal(Model.openSelection(summaryOf(kind, "onedrive@a.service"), "healthy"), "", + kind + " must not steal the selection") + } + // A deliberate pause is not a problem to be shown; nor is progress. + assert.equal(Model.needsAttention("paused"), false) + assert.equal(Model.needsAttention("syncing"), false) + assert.equal(Model.needsAttention("healthy"), false) + assert.equal(Model.needsAttention("checking"), false) + for (const kind of ["resync", "reauth", "failed", "missing", "login", "unavailable"]) { + assert.equal(Model.needsAttention(kind), true, kind + " must be actionable") + } +}) + +test("nothing to blame means nothing to select", () => { + assert.equal(Model.openSelection(summaryOf("reauth", ""), "healthy"), "") + assert.equal(Model.openSelection(summaryOf("reauth", null), "healthy"), "") + assert.equal(Model.openSelection(summaryOf("syncing", ""), "healthy"), "") + assert.equal(Model.openSelection(null, "healthy"), "") + assert.equal(Model.openSelection(undefined, "healthy"), "") +}) + +// --- what the bar icon does with an aggregate -------------------------------- + +test("the icon is lit whenever any account is working", () => { + assert.equal(Model.barState({ kind: "paused", anyActive: true, initialized: true }).active, true) + assert.equal(Model.barState({ kind: "healthy", anyActive: false, initialized: true }).active, false) +}) + +test("the icon spins for progress, and only for progress", () => { + assert.equal(Model.barState({ kind: "syncing" }).syncing, true) + assert.equal(Model.barState({ kind: "starting" }).syncing, true) + for (const kind of ["healthy", "paused", "reauth", "resync", "failed", "checking"]) { + assert.equal(Model.barState({ kind: kind }).syncing, false, kind) + } +}) + +test("one missing account does not dim a bar that has a healthy one syncing", () => { + // The regression a reviewer restored with the whole suite green: deriving the + // dim from the WORST account's kind blanked a bar that was working fine. + assert.equal(Model.barState({ + kind: "missing", anyActive: true, initialized: true }).installed, true) + assert.equal(Model.barState({ + kind: "unavailable", anyActive: true, initialized: true }).installed, true) +}) + +test("a fleet with nothing usable is dimmed", () => { + assert.equal(Model.barState({ + kind: "missing", anyActive: false, initialized: true }).installed, false) + assert.equal(Model.barState({ + kind: "unavailable", anyActive: false, initialized: true }).installed, false) + // ...but a merely paused fleet is installed, just idle. + assert.equal(Model.barState({ + kind: "paused", anyActive: false, initialized: true }).installed, true) +}) + +test("nothing is claimed before the first poll", () => { + const checking = Model.barState({ kind: "checking", anyActive: false, initialized: false }) + assert.equal(checking.installed, false) + assert.equal(checking.active, false) + assert.equal(checking.syncing, false) + // A missing aggregate must not throw on the way to the bar. + assert.deepEqual(Model.barState(null), { active: false, syncing: false, installed: false }) + assert.deepEqual(Model.barState(undefined), { active: false, syncing: false, installed: false }) +}) + +test("a helper that failed is not reported as a missing client", () => { + // A single account that has not reported carries default values, and the + // default `installed: false` renders as "OneDrive CLI is not installed" -- + // sending the user to look for a missing package when the status helper is + // what died. That is the ONE-account path; the multi-account branch already + // said "Checking N OneDrive accounts…". + const before = Model.aggregateTooltip([{ initialized: false }], Date.now()) + assert.equal(before, "Checking OneDrive…") + assert.ok(!before.includes("not installed"), before) + + const failed = Model.aggregateTooltip( + [{ initialized: false, attempted: true, lastError: "Could not run the OneDrive status helper" }], + Date.now()) + assert.ok(failed.includes("Could not run the OneDrive status helper"), failed) + assert.ok(!failed.includes("not installed"), failed) + + // ...and once it HAS reported, a genuinely missing client is still named. + const reallyMissing = Model.aggregateTooltip( + [{ initialized: true, attempted: true, installed: false }], Date.now()) + assert.ok(reallyMissing.includes("not installed"), reallyMissing) +}) + +test("an account whose helper keeps failing says so, however many accounts there are", () => { + // The one-account path already said this. With two or three accounts a + // permanently broken one was folded into "Checking N more…" -- a count that + // never goes down and never explains itself. And when python3 or the helper is + // missing outright, EVERY account is in that state at once, so the bar sat on + // "checking 3 accounts" for ever with no badge and no reason. + const dead = { + initialized: false, attempted: true, instance: "work", + description: "OneDrive sync (work account)", + lastError: "Could not run the OneDrive status helper" + } + const whole = Model.aggregateTooltip([dead, Object.assign({}, dead, { instance: "home" })], + Date.now()) + assert.ok(whole.includes("Could not run the OneDrive status helper"), whole) + // Named by their display names, so the user can tell which one is broken. + assert.ok(whole.includes("Work:"), whole) + assert.ok(whole.includes("Home:"), whole) + assert.ok(!whole.includes("Checking 2"), whole) + + // Mixed with a healthy account, it is listed rather than counted. + const mixed = Model.aggregateTooltip([account({ instance: "personal" }), dead], Date.now()) + assert.ok(mixed.includes("Could not run the OneDrive status helper"), mixed) + assert.ok(mixed.includes("Work:"), mixed) + assert.ok(!mixed.includes("Checking 1 more"), mixed) + + // An account that simply has not been polled YET is still just checking. + const early = Model.aggregateTooltip( + [account({ instance: "personal" }), { initialized: false, attempted: false }], Date.now()) + assert.ok(early.includes("Checking 1 more…"), early) + assert.ok(!early.includes("unavailable"), early) +}) + +// --- what the bar shows when one account is paused ---------------------------- +// +// Worst-first, INCLUDING a pause. For one round the badge let progress outrank a +// deliberate pause; the user overruled it: a paused account anywhere is a state +// you must be shown, and every account has to be working before the bar looks +// normal. + +test("one paused account puts the pause on the badge, whoever else is syncing", () => { + const fleet = [ + account({ instance: "work", running: false, active: false, activeState: "inactive" }), + account({ instance: "personal", syncing: true }), + account({ instance: "archive" }) + ] + const summary = Model.aggregateAccounts(fleet) + assert.equal(summary.kind, "paused") + assert.equal(summary.worst.instance, "work") + assert.equal(Model.badgeKind(summary.kind), "paused") + // The icon stays LIT -- two accounts are still working -- but it does not + // spin: the bar is reporting the pause, not the progress. + assert.equal(summary.anyActive, true) + assert.equal(Model.barState(summary).active, true) + assert.equal(Model.barState(summary).syncing, false) +}) + +test("a problem still outranks a pause, and a pause still outranks progress", () => { + const overrides = { + resync: { resyncRequired: true }, reauth: { reauthRequired: true }, + failed: { serviceFailed: true }, missing: { installed: false }, + login: { authenticated: false }, unavailable: { serviceAvailable: false } + } + for (const kind of Object.keys(overrides)) { + const summary = Model.aggregateAccounts([ + account(Object.assign({ instance: "bad" }, overrides[kind])), + account({ instance: "idle", running: false, active: false, activeState: "inactive" }), + account({ instance: "busy", syncing: true }) + ]) + assert.equal(summary.kind, kind, kind + " must reach the badge past the pause") + } + // ...and with every account at least working, progress shows. + const allWorking = Model.aggregateAccounts([ + account({ instance: "busy", syncing: true }), + account({ instance: "calm" }) + ]) + assert.equal(allWorking.kind, "syncing") + assert.equal(Model.barState(allWorking).syncing, true) +}) + +test("the tooltip still leads with the paused account", () => { + // The reminder must not be lost: the badge stops shouting about it, the + // tooltip still lists it first. + const text = Model.aggregateTooltip([ + account({ instance: "personal", syncing: true, statusText: "Syncing" }), + account({ instance: "work", running: false, active: false, + activeState: "inactive", statusText: "Sync paused" }) + ], Date.now()) + const lines = text.split("\n") + assert.ok(lines[1].startsWith("Work:"), text) +}) + +test("every badge kind has its own glyph, and they are all distinct", () => { + // Inverting any one of these mappings left the whole suite green: the bar + // would have shown the pause glyph for a reauth, and nothing would have said + // so. The glyphs are the only thing the user sees at eight pixels. + const glyphs = { + missing: "\u{f0156}", + login: "\u{f030b}", + paused: "\u{f03e4}", + syncing: "\u{f0453}", + attention: "\u{f002a}" + } + for (const kind of Object.keys(glyphs)) { + assert.equal(Model.badgeGlyph(kind), glyphs[kind], kind) + } + const drawn = Object.keys(glyphs).map(kind => Model.badgeGlyph(kind)) + assert.equal(new Set(drawn).size, drawn.length, "two badge kinds share a glyph") + // A healthy fleet, and anything unrecognised, draws no badge at all. + assert.equal(Model.badgeGlyph(""), "") + assert.equal(Model.badgeGlyph("healthy"), "") + assert.equal(Model.badgeGlyph("checking"), "") + assert.equal(Model.badgeGlyph(undefined), "") + // ...and every kind the aggregate can produce maps to a badge that has a + // glyph, or to none. Nothing may fall through to a blank badge by accident. + for (const kind of ["resync", "reauth", "failed", "missing", "login", + "unavailable", "paused", "starting", "syncing"]) { + const badge = Model.badgeKind(kind) + assert.notEqual(badge, "", kind + " must reach a badge") + assert.notEqual(Model.badgeGlyph(badge), "", kind + " -> " + badge + " must have a glyph") + } +}) + +test("the tooltip counts only the accounts it has not otherwise explained", () => { + const dead = { + initialized: false, attempted: true, instance: "work", + description: "OneDrive sync (work account)", lastError: "helper failed" + } + // Every account is broken: there is no one left to be "checking", and saying + // "Checking 0 more…" would be nonsense. + const all = Model.aggregateTooltip([dead, Object.assign({}, dead, { instance: "home" })], + Date.now()) + assert.ok(!all.includes("more…"), all) + // One broken, one not yet polled: exactly one is still checking. + const some = Model.aggregateTooltip( + [dead, { initialized: false, attempted: false }], Date.now()) + assert.ok(some.includes("Checking 1 more…"), some) +}) diff --git a/tests/Commands.test.js b/tests/Commands.test.js new file mode 100644 index 0000000..571c819 --- /dev/null +++ b/tests/Commands.test.js @@ -0,0 +1,337 @@ +const assert = require("node:assert") +const test = require("node:test") +const fs = require("node:fs") +const path = require("node:path") + +const Commands = require("../Commands.js") +const Model = require("../Model.js") + +const root = path.join(__dirname, "..") + +const PLAIN = { + service: "onedrive.service", + instance: "", + confdir: "/home/u/.config/onedrive", + description: "OneDrive Client for Linux" +} +const DRAGONES = { + service: "onedrive@dragones.service", + instance: "dragones", + confdir: "/home/u/.config/onedrive/accounts/dragones", + description: "OneDrive sync (dragones account)" +} + +test("resume units are collision-free and keep the legacy name for the plain service", () => { + assert.equal(Commands.resumeUnit(""), "omaonedrive-resume") + assert.equal(Commands.resumeUnit("dragones"), "omaonedrive-resume@dragones") + assert.equal(Commands.resumeUnit("personal"), "omaonedrive-resume@personal") + // Distinct instances can never collide on one unit name -- and the set has to + // include the shapes that can actually collide, not just well-behaved ones. + const units = ["", "dragones", "personal", "tandera"].map(Commands.resumeUnit) + assert.equal(new Set(units).size, units.length) +}) + +test("an instance that cannot yield a safe timer yields none at all", () => { + // 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 one + // account could cancel the other's pause. Refusing to derive is the safe + // answer; the caller falls back to an untimed pause. + assert.equal(Commands.resumeUnit("foo.timer"), "") + assert.equal(Commands.resumeUnit("foo.service"), "") + // ...and the collision it prevents: + assert.notEqual(Commands.resumeUnit("foo"), Commands.resumeUnit("foo.timer")) + + // The derived name must fit systemd's 255-byte limit -- and systemd-run creates + // BOTH a .timer and a .service, so the LONGER suffix is the binding one. + // These two lengths are the band where the two rules disagree: checking only + // ".timer" accepted them, and the account was then stopped by a pause whose + // timer could not be scheduled. + assert.equal(Commands.resumeUnit("x".repeat(229)), "", "229 must be refused (.service overflows)") + assert.equal(Commands.resumeUnit("x".repeat(231)), "") + const longest = Commands.resumeUnit("x".repeat(228)) + assert.notEqual(longest, "", "228 must still be accepted") + assert.ok(longest.length + ".service".length <= 255) + + // Every derived timer name, across shapes, is unique or empty. + const derived = ["", "foo", "foo.timer", "foo.service", "bar", "x".repeat(231)] + .map(Commands.resumeUnit).filter(unit => unit !== "") + assert.equal(new Set(derived).size, derived.length) +}) + +test("a status command omits a resume unit it could not derive", () => { + // Passing nothing is right: the helper then reports no resume time, rather + // than one belonging to a different account. + const command = Commands.status("/p/h.py", + { service: "onedrive@foo.timer.service", instance: "foo.timer", confdir: "/c" }, 20) + assert.ok(!command.includes("--resume-unit"), command.join(" ")) + assert.ok(!command.includes(""), command.join(" ")) +}) + +test("control vectors name the account's own service", () => { + assert.deepEqual( + Commands.control("stop", "onedrive@dragones.service"), + ["systemctl", "--user", "stop", "onedrive@dragones.service"]) + assert.deepEqual( + Commands.control("start", "onedrive.service"), + ["systemctl", "--user", "start", "onedrive.service"]) +}) + +test("interactive flows carry the account's own confdir", () => { + assert.deepEqual(Commands.login(DRAGONES.confdir), + ["omarchy-launch-terminal", "onedrive", "--confdir", DRAGONES.confdir]) + assert.deepEqual(Commands.login(DRAGONES.confdir, "reauth"), + ["omarchy-launch-terminal", "onedrive", "--confdir", DRAGONES.confdir, "--reauth"]) + assert.deepEqual(Commands.login(DRAGONES.confdir, "resync"), + ["omarchy-launch-terminal", "onedrive", "--confdir", DRAGONES.confdir, "--sync", "--resync"]) +}) + +test("an unspecified confdir is omitted from the interactive flows too", () => { + // Reachable before discovery supplies an identity: the seeded fallback + // descriptor has no confdir. "--confdir ''" is not the same as no --confdir -- + // the client would reject it -- and the old code ran a plain `onedrive` here. + assert.deepEqual(Commands.login(""), ["omarchy-launch-terminal", "onedrive"]) + assert.deepEqual(Commands.login("", "reauth"), + ["omarchy-launch-terminal", "onedrive", "--reauth"]) + assert.deepEqual(Commands.login("", "resync"), + ["omarchy-launch-terminal", "onedrive", "--sync", "--resync"]) + assert.deepEqual(Commands.login(null), ["omarchy-launch-terminal", "onedrive"]) + // No builder may ever emit an empty argument. + for (const vector of [Commands.login(""), Commands.login("", "reauth"), + Commands.status("/p/h.py", {}, 20)]) { + for (const argument of vector) assert.notEqual(argument, "", vector.join(" ")) + } +}) + +test("the plain account sends exactly today's command", () => { + // Before discovery supplies an identity, and for the plain service afterwards, + // the helper's own defaults ARE the single-account behaviour. Sending the + // default service or an empty confdir explicitly would be noise at best and, + // for an empty confdir, rejected by the helper's absolute-path rule. + assert.deepEqual( + Commands.status("/p/h.py", { service: "onedrive.service", instance: "", confdir: "" }, 20), + ["python3", "/p/h.py", "--limit", "20"]) + assert.deepEqual( + Commands.status("/p/h.py", {}, 20), + ["python3", "/p/h.py", "--limit", "20"]) + // ...but a discovered plain account still passes its real confdir. + assert.deepEqual( + Commands.status("/p/h.py", PLAIN, 20), + ["python3", "/p/h.py", "--confdir", PLAIN.confdir, "--limit", "20"]) +}) + +test("a non-default account with no confdir is not polled at all", () => { + // The helper independently re-derives the confdir in this case, so this is + // belt-and-braces -- but depending on that means the widget would silently + // report the DEFAULT account's token, quota and files under this account's + // name if that guard were ever relaxed. An empty command means "show nothing". + assert.deepEqual( + Commands.status("/p/h.py", { service: "onedrive@x.service", instance: "x", confdir: "" }, 20), + []) + // The plain service is unaffected: its default IS the correct behaviour. + assert.deepEqual( + Commands.status("/p/h.py", { service: "onedrive.service", instance: "", confdir: "" }, 20), + ["python3", "/p/h.py", "--limit", "20"]) +}) + +test("an empty status command is a refusal the caller must handle", () => { + // This shape bricked the widget once: startStatusProcess assigned the empty + // vector to a Process and set running = true. An empty command never launches, + // so onExited never fired, `refreshing` stayed true forever, and the + // coordinator's one-poll-at-a-time gate then froze EVERY account permanently. + // Account.startStatusProcess must bail before touching any state. + const refused = Commands.status("/p/h.py", + { service: "onedrive@x.service", instance: "x", confdir: "" }, 20) + assert.deepEqual(refused, []) + assert.equal(refused.length, 0) + + const source = fs.readFileSync(path.join(root, "Account.qml"), "utf8") + const fn = source.slice(source.indexOf("function startStatusProcess")) + const body = fn.slice(0, fn.indexOf("\n }")) + // The guard must come before `refreshing = true`, or the state is already + // corrupted by the time we return. + const guard = body.indexOf("command.length === 0") + const setsRefreshing = body.indexOf("refreshing = true") + assert.ok(guard !== -1, "startStatusProcess has no empty-command guard") + assert.ok(guard < setsRefreshing, + "the empty-command guard must precede `refreshing = true`") +}) + +test("the status command is account-complete", () => { + const command = Commands.status("/p/onedrive-status.py", DRAGONES, 20) + assert.deepEqual(command, [ + "python3", "/p/onedrive-status.py", + "--service", "onedrive@dragones.service", + "--confdir", DRAGONES.confdir, + "--resume-unit", "omaonedrive-resume@dragones", + "--limit", "20" + ]) + // The three identity flags must agree with each other on every invocation. + assert.equal(command[command.indexOf("--service") + 1], DRAGONES.service) + assert.equal(command[command.indexOf("--confdir") + 1], DRAGONES.confdir) + assert.equal(command[command.indexOf("--resume-unit") + 1], + Commands.resumeUnit(DRAGONES.instance)) +}) + +test("cloud modes add exactly one flag, in the right place", () => { + // Exact vectors, not membership: a stray extra flag would make a quota + // refresh also run the slow full-drive check, and membership cannot see that. + const base = ["python3", "/p/h.py", "--confdir", PLAIN.confdir, "--limit", "5"] + assert.deepEqual(Commands.status("/p/h.py", PLAIN, 5), base) + assert.deepEqual(Commands.status("/p/h.py", PLAIN, 5, "quota"), base.concat(["--quota"])) + assert.deepEqual(Commands.status("/p/h.py", PLAIN, 5, "sync-status"), base.concat(["--sync-status"])) + // An unknown mode adds nothing rather than guessing. + assert.deepEqual(Commands.status("/p/h.py", PLAIN, 5, "nonsense"), base) +}) + +test("a timed pause cancels and schedules only its own account", () => { + const unit = Commands.resumeUnit(DRAGONES.instance) + assert.deepEqual(Commands.cancelResume(unit), + ["systemctl", "--user", "stop", + "omaonedrive-resume@dragones.timer", "omaonedrive-resume@dragones.service"]) + + const schedule = Commands.scheduleResume(unit, DRAGONES.service, 15) + assert.deepEqual(schedule, [ + "systemd-run", "--user", + "--unit=omaonedrive-resume@dragones", + "--description=Resume OneDrive after timed pause", + "--on-active=15m", + "--timer-property=AccuracySec=1s", + "--collect", + "/usr/bin/systemctl", "--user", "start", "onedrive@dragones.service" + ]) + // The timer must start the same service the pause stopped. + assert.equal(schedule[schedule.length - 1], DRAGONES.service) + // ...and must not mention any other account. + assert.ok(!schedule.join(" ").includes("personal")) +}) + +test("the plain service keeps today's exact vectors", () => { + const unit = Commands.resumeUnit(PLAIN.instance) + assert.deepEqual(Commands.cancelResume(unit), + ["systemctl", "--user", "stop", "omaonedrive-resume.timer", "omaonedrive-resume.service"]) + assert.deepEqual(Commands.scheduleResume(unit, PLAIN.service, 60), [ + "systemd-run", "--user", + "--unit=omaonedrive-resume", + "--description=Resume OneDrive after timed pause", + "--on-active=60m", + "--timer-property=AccuracySec=1s", + "--collect", + "/usr/bin/systemctl", "--user", "start", "onedrive.service" + ]) +}) + +test("--resync appears only in the interactive terminal vector", () => { + // Every non-interactive builder, exercised, must be free of the mutating flags. + const vectors = [ + Commands.status("/p/h.py", DRAGONES, 20), + Commands.status("/p/h.py", DRAGONES, 20, "quota"), + Commands.status("/p/h.py", DRAGONES, 20, "sync-status"), + Commands.listAccounts("/p/h.py"), + Commands.control("start", DRAGONES.service), + Commands.control("stop", DRAGONES.service), + Commands.cancelResume(Commands.resumeUnit(DRAGONES.instance)), + Commands.scheduleResume(Commands.resumeUnit(DRAGONES.instance), DRAGONES.service, 15), + Commands.notify("normal", "s", "b") + ] + for (const vector of vectors) { + const joined = vector.join(" ") + assert.ok(!joined.includes("--resync"), joined) + assert.ok(!joined.includes("--logout"), joined) + } + // ...and it must actually BE there. Without this, deleting the push in + // Commands.login leaves the test green while Repair silently stops repairing. + const repair = Commands.login(DRAGONES.confdir, "resync") + assert.equal(repair[0], "omarchy-launch-terminal") + assert.ok(repair.includes("--resync"), repair.join(" ")) + assert.ok(repair.includes("--sync"), repair.join(" ")) +}) + +test("only the interactive vector may carry a mutating token", () => { + // Token-level, not substring: the old shell grep banned a bare --sync as well + // as --resync and --logout, and "--sync-status" must not false-positive. A new + // builder that leaked one of these would otherwise pass every other check. + const FORBIDDEN = ["--sync", "--resync", "--logout", "--reauth"] + const nonInteractive = { + status: Commands.status("/p/h.py", DRAGONES, 20), + statusQuota: Commands.status("/p/h.py", DRAGONES, 20, "quota"), + statusSync: Commands.status("/p/h.py", DRAGONES, 20, "sync-status"), + listAccounts: Commands.listAccounts("/p/h.py"), + controlStart: Commands.control("start", DRAGONES.service), + controlStop: Commands.control("stop", DRAGONES.service), + cancelResume: Commands.cancelResume("omaonedrive-resume@dragones"), + scheduleResume: Commands.scheduleResume("omaonedrive-resume@dragones", DRAGONES.service, 15), + notify: Commands.notify("normal", "s", "b") + } + for (const [name, vector] of Object.entries(nonInteractive)) { + for (const token of vector) { + assert.ok(!FORBIDDEN.includes(token), name + " leaked " + token) + } + } + // --sync-status is a distinct token and must survive the check above. + assert.ok(nonInteractive.statusSync.includes("--sync-status")) +}) + +test("no builder ever produces a shell invocation", () => { + const vectors = [ + Commands.status("/p/h.py", DRAGONES, 20), + Commands.listAccounts("/p/h.py"), + Commands.control("start", DRAGONES.service), + Commands.login(DRAGONES.confdir, "reauth"), + Commands.cancelResume("omaonedrive-resume"), + Commands.scheduleResume("omaonedrive-resume", PLAIN.service, 5), + Commands.notify("critical", "s", "b", { id: "resync", label: "Run resync repair" }) + ] + for (const vector of vectors) { + assert.ok(Array.isArray(vector)) + for (const argument of vector) assert.equal(typeof argument, "string") + assert.ok(!["sh", "bash", "/bin/sh", "/bin/bash", "env"].includes(vector[0]), vector[0]) + assert.ok(!vector.includes("-c"), vector.join(" ")) + } +}) + +test("the source itself contains no shell construction", () => { + const source = fs.readFileSync(path.join(root, "Commands.js"), "utf8") + assert.ok(!/\bbash\b|\bsh -c\b|execDetached\(\s*"/.test(source)) +}) + +test("account names are labels, not systemd sentences", () => { + assert.equal(Model.accountName("dragones"), "Dragones") + assert.equal(Model.accountName("personal"), "Personal") + assert.equal(Model.accountName("work-mail"), "Work Mail") + assert.equal(Model.accountName("work_mail"), "Work Mail") + // The plain service has no instance and keeps today's identity. + assert.equal(Model.accountName("", "OneDrive Client for Linux"), "OneDrive") + assert.equal(Model.accountName(null), "OneDrive") +}) + +test("notifications carry their click as a persisted exec hint", () => { + // Adapted from upstream 1.5.5/1.5.6: the daemon persists --exec and runs it + // on click, so the click works after a shell or plugin reload -- the old + // tracked notify-send read the click back from stdout and died with the + // process. Everything after --exec is the click command, one argv element + // per word, with the ACCOUNT riding along -- never joined into a string. + assert.deepEqual( + Commands.notify("critical", "OneDrive needs a resync", "Body.", "repair", "onedrive@dragones.service"), + ["omarchy-notification-send", "--app-name", "OmaOneDrive", "--urgency", "critical", + "OneDrive needs a resync", "Body.", + "--exec", "omarchy-shell", "io.github.salemsayed.omaonedrive", "repairAccount", "onedrive@dragones.service"]) + assert.deepEqual( + Commands.notify("critical", "OneDrive needs reauthentication", "Body.", "open", "onedrive@personal.service"), + ["omarchy-notification-send", "--app-name", "OmaOneDrive", "--urgency", "critical", + "OneDrive needs reauthentication", "Body.", + "--exec", "omarchy-shell", "io.github.salemsayed.omaonedrive", "openAccount", "onedrive@personal.service"]) + // No behaviour, no exec: the recovered/storage popups are informational. + assert.deepEqual( + Commands.notify("normal", "OneDrive recovered", "Sync is healthy.", "", ""), + ["omarchy-notification-send", "--app-name", "OmaOneDrive", "--urgency", "normal", + "OneDrive recovered", "Sync is healthy."]) + // A behaviour with no account degrades to the accountless IPC calls, which + // act on the selected account -- today's single-account behaviour. + assert.deepEqual(Commands.notificationExec("open", ""), + ["omarchy-shell", "io.github.salemsayed.omaonedrive", "open"]) + assert.deepEqual(Commands.notificationExec("repair", ""), + ["omarchy-shell", "io.github.salemsayed.omaonedrive", "resync"]) + // An unrecognised behaviour is a closed door, not a command. + assert.deepEqual(Commands.notificationExec("arbitrary user input", "x"), []) + assert.deepEqual(Commands.notificationExec("", "onedrive@a.service"), []) +}) diff --git a/tests/Discovery.test.js b/tests/Discovery.test.js new file mode 100644 index 0000000..a04aa5e --- /dev/null +++ b/tests/Discovery.test.js @@ -0,0 +1,135 @@ +const assert = require("node:assert") +const test = require("node:test") + +const Model = require("../Model.js") + +function row(service, instance, confdir) { + return { + service: service, + instance: instance === undefined ? service.replace(/^onedrive@|\.service$/g, "") : instance, + confdir: confdir || ("/c/" + service), + description: "OneDrive sync" + } +} + +const PLAIN = row("onedrive.service", "", "/c/default") +const DRAGONES = row("onedrive@dragones.service", "dragones") +const PERSONAL = row("onedrive@personal.service", "personal") +const TANDERA = row("onedrive@tandera.service", "tandera") + +test("first discovery appends everything", () => { + const plan = Model.reconcilePlan([], [DRAGONES, PERSONAL, TANDERA]) + assert.equal(plan.appends.length, 3) + assert.equal(plan.updates.length, 0) + assert.equal(plan.removes.length, 0) +}) + +test("an unchanged set is all updates and no churn", () => { + // This is the property that matters: a repeat discovery must not recreate a + // single delegate, or in-flight processes and notification latches are lost. + const current = [DRAGONES.service, PERSONAL.service, TANDERA.service] + const plan = Model.reconcilePlan(current, [DRAGONES, PERSONAL, TANDERA]) + assert.equal(plan.appends.length, 0) + assert.equal(plan.removes.length, 0) + // deepEqual on the ROW, not just the index. Pinning only the index let every + // update carry the same row, which would give every account the first + // account's confdir -- they would all poll and display one drive. + assert.deepEqual(plan.updates, [ + { index: 0, row: DRAGONES }, + { index: 1, row: PERSONAL }, + { index: 2, row: TANDERA } + ]) +}) + +test("each update carries its OWN row, not a neighbour's", () => { + // Reordered discovery: the rows must still pair with the right descriptors. + const current = [DRAGONES.service, PERSONAL.service, TANDERA.service] + const plan = Model.reconcilePlan(current, [TANDERA, DRAGONES, PERSONAL]) + const byIndex = {} + for (const update of plan.updates) byIndex[update.index] = update.row.service + assert.deepEqual(byIndex, { + 0: DRAGONES.service, + 1: PERSONAL.service, + 2: TANDERA.service + }) +}) + +test("a changed confdir updates in place rather than replacing the account", () => { + const moved = Object.assign({}, DRAGONES, { confdir: "/srv/moved" }) + const plan = Model.reconcilePlan([DRAGONES.service], [moved]) + assert.equal(plan.appends.length, 0) + assert.equal(plan.removes.length, 0) + assert.deepEqual(plan.updates, [{ index: 0, row: moved }]) +}) + +test("a new account appends without disturbing existing indices", () => { + const plan = Model.reconcilePlan([DRAGONES.service], [DRAGONES, PERSONAL]) + assert.deepEqual(plan.updates.map(u => u.index), [0]) + assert.deepEqual(plan.appends, [PERSONAL]) + assert.equal(plan.removes.length, 0) +}) + +test("a removed account is dropped, and removals are descending", () => { + const current = [DRAGONES.service, PERSONAL.service, TANDERA.service] + const plan = Model.reconcilePlan(current, [PERSONAL]) + assert.deepEqual(plan.updates.map(u => u.index), [1]) + // Descending, so applying one cannot shift the next. + assert.deepEqual(plan.removes, [2, 0]) + for (let index = 1; index < plan.removes.length; index++) { + assert.ok(plan.removes[index] < plan.removes[index - 1]) + } +}) + +test("everything disappearing removes everything", () => { + const plan = Model.reconcilePlan([DRAGONES.service, PERSONAL.service], []) + assert.deepEqual(plan.removes, [1, 0]) + assert.equal(plan.appends.length, 0) +}) + +test("reordered discovery does not churn delegates", () => { + // Discovery sorts by instance; a rename elsewhere could reorder it. Existing + // services must still map to their existing rows, not be torn down. + const current = [DRAGONES.service, PERSONAL.service] + const plan = Model.reconcilePlan(current, [PERSONAL, DRAGONES]) + assert.equal(plan.appends.length, 0) + assert.equal(plan.removes.length, 0) + assert.deepEqual(plan.updates.map(u => u.index).sort(), [0, 1]) +}) + +test("malformed discovery rows are ignored, not turned into accounts", () => { + const plan = Model.reconcilePlan([], [null, {}, { service: "" }, DRAGONES, "nonsense"]) + assert.deepEqual(plan.appends, [DRAGONES]) +}) + +test("a repeated service in one payload is taken once", () => { + const plan = Model.reconcilePlan([], [DRAGONES, DRAGONES]) + assert.equal(plan.appends.length, 1) +}) + +test("the plain account is reconciled like any other", () => { + // The single-account fallback is a first-class descriptor, not a special path. + const plan = Model.reconcilePlan([PLAIN.service], [PLAIN]) + assert.deepEqual(plan.updates, [{ index: 0, row: PLAIN }]) + assert.equal(plan.appends.length, 0) + assert.equal(plan.removes.length, 0) +}) + +test("the seeded fallback is replaced in place when discovery names it", () => { + // Startup seeds { onedrive.service, confdir: "" }; discovery returns the same + // service with a real confdir. That must update the existing delegate rather + // than remove-and-append, which would restart its processes. + const seeded = ["onedrive.service"] + const discovered = [Object.assign({}, PLAIN, { confdir: "/home/u/.config/onedrive" })] + const plan = Model.reconcilePlan(seeded, discovered) + assert.equal(plan.updates.length, 1) + assert.equal(plan.appends.length, 0) + assert.equal(plan.removes.length, 0) +}) + +test("switching from the plain account to templates removes the plain one", () => { + // A machine that stops using onedrive.service and starts three instances. + const plan = Model.reconcilePlan(["onedrive.service"], [DRAGONES, PERSONAL, TANDERA]) + assert.deepEqual(plan.removes, [0]) + assert.equal(plan.appends.length, 3) + assert.equal(plan.updates.length, 0) +}) diff --git a/tests/IpcLifecycle.test.js b/tests/IpcLifecycle.test.js index 54cb303..f804208 100644 --- a/tests/IpcLifecycle.test.js +++ b/tests/IpcLifecycle.test.js @@ -4,13 +4,6 @@ const path = require("node:path") const test = require("node:test") const source = readFileSync(path.join(__dirname, "..", "BarWidget.qml"), "utf8") -const modelSource = readFileSync(path.join(__dirname, "..", "Model.js"), "utf8") -const serviceSource = readFileSync(path.join(__dirname, "..", "Service.qml"), "utf8") -const presentationSource = ["Panel.qml", "StatusBadge.qml"] - .map(name => readFileSync(path.join(__dirname, "..", name), "utf8")) - .join("\n") -const manifest = JSON.parse(readFileSync(path.join(__dirname, "..", "manifest.json"), "utf8")) -const changelog = readFileSync(path.join(__dirname, "..", "CHANGELOG.md"), "utf8") test("IPC registration waits for a relocated bar slot to retire", () => { assert.match(source, /property bool ipcRegistrationReady: false/) @@ -18,32 +11,104 @@ test("IPC registration waits for a relocated bar slot to retire", () => { assert.match(source, /IpcHandler \{\s+enabled: root\.ipcRegistrationReady\s+target: root\.moduleName/) }) -test("notification actions use Omarchy's durable exec hint with argv", () => { - assert.match(serviceSource, /Model\.notificationCommand\(urgency, summary, body, behavior\)/) - assert.doesNotMatch(serviceSource, /notificationActionCommand/) - assert.doesNotMatch(serviceSource, /"--exec", actionCommand/) - assert.doesNotMatch(serviceSource, /"notify-send"/) - assert.doesNotMatch(serviceSource, /--action=default=/) +// --- the account IPC surface ------------------------------------------------- +// +// `accounts()` and `selectAccount()` are how automation reaches a non-default +// account: every other IPC control acts on the SELECTED one, so a wrong answer +// here silently redirects a pause, a resync or a reauth to another account. +// Both were previously deletable outright with the whole suite still green. + +const Model = require("../Model.js") + +function account(instance, overrides) { + return Object.assign({ + service: instance === "" ? "onedrive.service" : "onedrive@" + instance + ".service", + instance: instance, + displayName: instance === "" ? "OneDrive" : instance, + statusText: "Monitoring" + }, overrides || {}) +} + +const THREE = [account(""), account("personal"), account("dragones")] + +test("accounts() reports every account, in discovery order, with one selected", () => { + const rows = Model.accountRows(THREE, "onedrive@personal.service") + assert.equal(rows.length, 3) + assert.deepEqual(rows.map(row => row.instance), ["", "personal", "dragones"]) + assert.deepEqual(rows.map(row => row.selected), [false, true, false]) + assert.equal(rows.filter(row => row.selected).length, 1) + assert.deepEqual(rows[1], { + service: "onedrive@personal.service", + instance: "personal", + name: "personal", + selected: true, + status: "Monitoring" + }) + // Round-trips as JSON, which is what the IPC caller actually receives. + assert.deepEqual(JSON.parse(JSON.stringify(rows)), rows) +}) + +test("a selection that matches nothing marks nothing selected", () => { + // Reachable during discovery: selectedService is cleared before the new list + // settles. Reporting a stale `selected: true` would point a script at an + // account the widget is no longer acting on. + const rows = Model.accountRows(THREE, "onedrive@gone.service") + assert.deepEqual(rows.map(row => row.selected), [false, false, false]) + assert.deepEqual(Model.accountRows([], "onedrive.service"), []) + assert.deepEqual(Model.accountRows(null, ""), []) + // An empty selection must not select the plain account by accident of both + // being falsy. + assert.deepEqual(Model.accountRows(THREE, "").map(row => row.selected), + [false, false, false]) +}) + +test("selectAccount accepts the full unit name and the bare instance", () => { + assert.equal(Model.resolveAccountTarget(THREE, "onedrive@dragones.service"), + "onedrive@dragones.service") + assert.equal(Model.resolveAccountTarget(THREE, "dragones"), "onedrive@dragones.service") + assert.equal(Model.resolveAccountTarget(THREE, "onedrive.service"), "onedrive.service") +}) + +test("an unknown or empty target resolves to nothing rather than to an account", () => { + // "" is what an unset argument arrives as, and the plain account's instance is + // also "". No account key can equal "" today, so this pins a contract rather + // than catching a live mutation -- but the day an account carries an empty + // service or instance, an unset argument must still resolve to nothing. + assert.equal(Model.resolveAccountTarget(THREE, ""), "") + assert.equal(Model.resolveAccountTarget(THREE, null), "") + assert.equal(Model.resolveAccountTarget(THREE, "personal.service"), "") + assert.equal(Model.resolveAccountTarget(THREE, "Personal"), "") + assert.equal(Model.resolveAccountTarget([], "personal"), "") }) -test("every text surface renders helper and file data as literal plain text", () => { - const textItems = presentationSource.match(/\bText\s*\{/g) || [] - const plainTextItems = presentationSource.match( - /\bText\s*\{\s*textFormat:\s*Text\.PlainText\b/g - ) || [] - const sectionHeaders = presentationSource.match(/\bPanelSectionHeader\s*\{/g) || [] - const plainSectionHeaders = presentationSource.match( - /\bPanelSectionHeader\s*\{\s*textFormat:\s*Text\.PlainText\b/g - ) || [] - assert.equal(textItems.length, 22) - assert.equal(plainTextItems.length, textItems.length) - assert.equal(sectionHeaders.length, 3) - assert.equal(plainSectionHeaders.length, sectionHeaders.length) - assert.match(modelSource, /function inheritedPlainText\(value\)/) - assert.match(modelSource, /return inheritedPlainText\(parts\.join\(" · "\)\)/) +test("an exact unit name beats another account's instance, whatever the order", () => { + // systemd instance names may contain dots, so an instance CAN equal another + // account's unit name. Testing both keys account-by-account made the winner + // depend on discovery order, and the control then acted on the wrong account. + const collide = [ + account("legacy", { instance: "onedrive.service" }), + account("") + ] + assert.equal(Model.resolveAccountTarget(collide, "onedrive.service"), "onedrive.service") + // ...and reversed, so this cannot pass by the order of the fixture. + assert.equal(Model.resolveAccountTarget(collide.slice().reverse(), "onedrive.service"), + "onedrive.service") + // The instance form still reaches the account that has no unambiguous rival. + assert.equal(Model.resolveAccountTarget(collide, "legacy"), "") }) -test("release metadata stays synchronized", () => { - assert.match(manifest.version, /^\d+\.\d+\.\d+$/) - assert.match(changelog, new RegExp(`^## ${manifest.version.replaceAll(".", "\\.")} - \\d{4}-\\d{2}-\\d{2}$`, "m")) +test("the IPC handler still exposes both account functions, and selects without a cloud call", () => { + // The pure functions above are worthless if nothing calls them. These pin the + // wiring, which is the half that lives in a file no harness can instantiate. + const handler = source.slice(source.indexOf("IpcHandler {")) + assert.match(handler, /function accounts\(\): string/) + assert.match(handler, /function selectAccount\(target: string\): string/) + assert.match(handler, /Model\.accountRows\(root\.service\.accounts, root\.service\.selectedService\)/) + assert.match(handler, /Model\.resolveAccountTarget\(root\.service\.accounts, target\)/) + // The second argument is the fix from an earlier round: automation selecting + // an account must not inherit the panel's stale-quota retry, which contacts + // Microsoft. Dropping the `false` restores that. + assert.match(handler, /root\.service\.selectAccount\(found, false\)/) + // An unresolved target must report, not silently act on whatever is selected. + assert.match(handler, /return "unknown account: " \+ target/) }) diff --git a/tests/Model.test.js b/tests/Model.test.js index 8b5bc54..5b92d8a 100644 --- a/tests/Model.test.js +++ b/tests/Model.test.js @@ -111,6 +111,88 @@ test("folder, tooltip, and plugin paths handle spaces", () => { statusText: "Monitoring", syncMode: "Upload only" }), "Monitoring · Upload only") + assert.equal(Model.filePath("file:///tmp/Oma%20OneDrive/status.py"), "/tmp/Oma OneDrive/status.py") +}) + +test("an error line is squashed to one line and capped", () => { + // systemd and the onedrive client both emit multi-line errors hundreds of + // characters long. Pasted straight into the panel they pushed every other row + // off the screen. + assert.equal(Model.elideStatus(" one\n two \t three "), "one two three") + assert.equal(Model.elideStatus(""), "") + assert.equal(Model.elideStatus(null), "") + assert.equal(Model.elideStatus(undefined), "") + + const long = "x".repeat(400) + const cut = Model.elideStatus(long) + assert.equal(cut.length, 178, cut.length + " characters") + assert.ok(cut.endsWith("…")) + // The boundary: 180 is kept whole, 181 is cut. + assert.equal(Model.elideStatus("y".repeat(180)).length, 180) + assert.ok(!Model.elideStatus("y".repeat(180)).endsWith("…")) + assert.ok(Model.elideStatus("y".repeat(181)).endsWith("…")) + // A tall error is squashed FIRST and capped second, so what survives is 177 + // characters of message rather than 177 characters of indentation. + const tall = Model.elideStatus(" a\n".repeat(100)) + assert.ok(!tall.includes("\n"), tall) + assert.ok(!tall.includes(" "), tall) + assert.equal(tall.length, 178) + assert.ok(tall.startsWith("a a a a"), tall) + // Whitespace alone is nothing to report. + assert.equal(Model.elideStatus(" \n\t "), "") +}) + +test("relative times step through every unit, including the ones nobody reaches", () => { + // Inverting the month/year boundary left the suite green. A stale account is + // exactly where these matter: "13mo ago" and "1y ago" are the difference + // between a sync that is old and one that never happened. + const now = Date.UTC(2026, 0, 1) + const ago = seconds => Model.relativeTime(now / 1000 - seconds, now) + assert.equal(ago(30), "Just now") + assert.equal(Model.relativeTime(0, now), "Never") + assert.equal(ago(60 * 5), "5m ago") + assert.equal(ago(60 * 60 * 3), "3h ago") + assert.equal(ago(60 * 60 * 24 * 5), "5d ago") + assert.equal(ago(60 * 60 * 24 * 60), "2mo ago") + // The boundary the inversion crossed: 11 months is months, 12 is years. + assert.equal(ago(60 * 60 * 24 * 30 * 11), "11mo ago") + assert.equal(ago(60 * 60 * 24 * 400), "1y ago") + assert.equal(ago(60 * 60 * 24 * 800), "2y ago") +}) + +test("a file's glyph follows its kind, and each kind has its own", () => { + // Literal glyphs, not "whatever this kind currently returns": deriving the + // expectation from the code under test made the whole check self-consistent, + // and inverting the document branch stayed green. + const byKind = { image: "\u{f02e9}", video: "\u{f022b}", document: "\u{f0219}", other: "\u{f0214}" } + assert.equal(Model.fileGlyph("a.png"), byKind.image) + assert.equal(Model.fileGlyph("a.mp4"), byKind.video) + assert.equal(Model.fileGlyph("a.docx"), byKind.document) + assert.equal(Model.fileGlyph("a.bin"), byKind.other) + const drawn = Object.values(byKind) + assert.equal(new Set(drawn).size, drawn.length, "two file kinds share a glyph") + assert.equal(Model.fileGlyph("report.pdf"), byKind.document) + assert.equal(Model.fileGlyph("notes.txt"), byKind.document) + assert.equal(Model.fileGlyph("photo.JPG"), byKind.image, "extensions are case-insensitive") + assert.equal(Model.fileGlyph(""), byKind.other) +}) + +test("the hero line names the sync mode only when it means something", () => { + // Before sign-in the client reports a mode it is not using. Showing + // "Sign in required · Two-way" reads as though syncing were configured. + assert.equal(Model.heroMeta({ statusText: "Monitoring", authenticated: true, syncMode: "Two-way" }), + "Monitoring · Two-way") + assert.equal(Model.heroMeta({ statusText: "Sign in required", authenticated: false, syncMode: "Two-way" }), + "Sign in required") + assert.equal(Model.heroMeta({ statusText: "Monitoring", authenticated: true, syncMode: "" }), + "Monitoring") + assert.equal(Model.heroMeta(null), "Checking…") +}) + +test("markup in helper data cannot become rich text at inherited boundaries", () => { + // From upstream 1.5.6: Omarchy 4.0.1's shared bar tooltip and PanelHero use + // Text.AutoText, so a filename shaped like markup would render as markup. + // The delimiters are swapped for lookalikes, so the name stays readable. const markupStatus = { installed: true, authenticated: true, @@ -118,42 +200,28 @@ test("folder, tooltip, and plugin paths handle spaces", () => { syncMode: "Two-way", lastSyncTs: 0 } - assert.equal( - Model.tooltip(markupStatus), - "Uploading ‹b›quarterly report‹/b›.pdf · Two-way" - ) - assert.equal( - Model.heroMeta(markupStatus), - "Uploading ‹b›quarterly report‹/b›.pdf · Two-way" - ) + assert.equal(Model.tooltip(markupStatus), + "Uploading ‹b›quarterly report‹/b›.pdf · Two-way") + assert.equal(Model.heroMeta(markupStatus), + "Uploading ‹b›quarterly report‹/b›.pdf · Two-way") assert.doesNotMatch(Model.tooltip(markupStatus), /[<>]/) - assert.equal(Model.filePath("file:///tmp/Oma%20OneDrive/status.py"), "/tmp/Oma OneDrive/status.py") -}) -test("notification click actions follow Omarchy 4.0.1's safe argv contract", () => { - assert.deepEqual( - Model.notificationActionArgv("open"), - ["omarchy-shell", "io.github.salemsayed.omaonedrive", "open"] - ) - assert.deepEqual( - Model.notificationActionArgv("repair"), - ["omarchy-shell", "io.github.salemsayed.omaonedrive", "resync"] - ) - assert.deepEqual(Model.notificationActionArgv("arbitrary user input"), []) - - assert.deepEqual( - Model.notificationCommand("critical", "OneDrive failed", "Open the panel.", "open"), - [ - "omarchy-notification-send", "--app-name", "OmaOneDrive", "--urgency", "critical", - "OneDrive failed", "Open the panel.", "--exec", - "omarchy-shell", "io.github.salemsayed.omaonedrive", "open" - ] - ) - assert.deepEqual( - Model.notificationCommand("normal", "OneDrive recovered", "Sync is healthy.", ""), - [ - "omarchy-notification-send", "--app-name", "OmaOneDrive", "--urgency", "normal", - "OneDrive recovered", "Sync is healthy." - ] - ) + // The multi-account tooltip is the SAME shared bar boundary, and its lines + // carry helper-derived names and errors too. + const fleet = Model.aggregateTooltip([ + { initialized: true, installed: true, authenticated: true, serviceAvailable: true, + running: true, activeState: "active", instance: "a