From 21818ff91c177c0a1bee52a4b6e9f33dbd1f4292 Mon Sep 17 00:00:00 2001 From: Christian Kreiling Date: Fri, 23 Oct 2026 19:07:56 -0700 Subject: [PATCH 1/3] Add compact VM details, confirmed management actions, and floating REPL --- CHANGELOG.md | 12 +- CONTRIBUTING.md | 18 +- Model.js | 88 ++++++ README.md | 83 +++++- SECURITY.md | 14 +- ShortcutHint.qml | 40 +++ VmDetail.qml | 339 +++++++++++++++++++++++ VmNavigation.qml | 86 ++++++ VmService.qml | 96 +++++-- Widget.qml | 596 ++++++++++++++++++++++++---------------- manifest.json | 11 +- tests/commands.test.cjs | 54 ++++ tests/manifest.test.cjs | 8 +- 13 files changed, 1171 insertions(+), 274 deletions(-) create mode 100644 ShortcutHint.qml create mode 100644 VmDetail.qml create mode 100644 VmNavigation.qml create mode 100644 tests/commands.test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 46b0c11..6072b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,17 @@ ## 1.0.0 -Initial release. +Initial release (in preparation). + +- Compact VM details on Enter, with a shared hostname, resource summary, metadata + grid, and browser/SSH buttons; no duplicate SSH/web address blocks. +- Live human-readable creation age beside the VM name. +- Confirmed restart, pause/resume, and copy operations with admin checks and + bounded background commands. +- E opens the exe.dev REPL in a new floating terminal. +- Row-wide click action: SSH by default, configurable to browser or details. +- Persistent S/B shortcut guide with no per-row action buttons. +- Details track VM identity across refreshes and preserve the filtered list on return. - Native Omarchy Quattro bar widget for exe.dev virtual machines. - Tag-grouped VM list with browser and SSH actions. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6faf3c7..d1d1bf7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ There are no npm dependencies or build step. Use Node.js 22 or newer: ```sh node --test tests/*.test.cjs omarchy plugin validate . -qmllint -I "$OMARCHY_PATH/shell" Widget.qml VmService.qml +qmllint -I "$OMARCHY_PATH/shell" Widget.qml VmService.qml VmNavigation.qml VmDetail.qml ShortcutHint.qml git diff --check ``` @@ -35,7 +35,21 @@ On a supported Omarchy session, test: letters does not activate them; Enter returns to the list without launching. - Arrow navigation, scrolling, browser/SSH selection, and hover selection target the same displayed VM, including after a refresh or filter change. -- Both launch actions, access-disabled buttons, refresh, and middle-click. +- S/B launch from both list and details; list Enter opens details; details Enter + launches the selected action. Escape returns to the same query and selection. +- Row click defaults to SSH; test browser/details click settings, including + access-denied actions. There must be no action buttons on list rows. +- Detail refresh tracks the same VM by name; removal returns to the list safely. +- SSH/browser buttons, access-disabled buttons, refresh, and middle-click. +- Creation age boundaries (just now/minutes/hours/days), live age updates, and + missing/invalid timestamps; the exact date should only appear on hover. +- E opens a new floating terminal in the exe.dev REPL from the list, including + an empty account. While filtering, E must type normally. +- Management actions require explicit admin access and named confirmation. + Escape cancels; Enter confirms once; no S/B shortcut may bypass confirmation. + Check success/error/timeout messages and the refreshed list afterward. + Exercise restart, pause/resume, and copy only on a disposable VM with the + account owner's permission; copy can consume additional capacity. - Empty account, failed authentication, unavailable network, malformed response, and recovery; stale results remain visible after a failed refresh. - Disable/re-enable, Git-based update, and removal. Do not remove a checkout diff --git a/Model.js b/Model.js index d8d8175..fd10f92 100644 --- a/Model.js +++ b/Model.js @@ -23,12 +23,79 @@ function normalizeVm(raw) { // The requested canonical VM URL, not arbitrary server-provided schemes. url: "https://" + name + ".exe.xyz/", status: String(raw.status || "unknown"), + cpus: metadataNumber(raw.allocated_cpus), + memoryBytes: metadataNumber(raw.memory_capacity_bytes), + diskBytes: metadataNumber(raw.disk_capacity_bytes), + region: typeof raw.region === "string" ? raw.region : "", + regionDisplay: typeof raw.region_display === "string" ? raw.region_display : "", + image: typeof raw.image === "string" ? raw.image : "", + createdAt: typeof raw.created_at === "string" ? raw.created_at : "", + proxyPort: Number.isInteger(raw.proxy_port) && raw.proxy_port > 0 && raw.proxy_port <= 65535 ? raw.proxy_port : 0, + egressPolicy: typeof raw.egress_policy === "string" ? raw.egress_policy : "", + emailReceiveEnabled: typeof raw.email_receive_enabled === "boolean" ? raw.email_receive_enabled : null, publicWeb: raw.proxy_share === "public" || !!(raw.sharing && raw.sharing.public_proxy), + adminAllowed: !!(raw.access && raw.access.admin === true), shellAllowed: !(raw.access && raw.access.shell === false), webAllowed: !(raw.access && raw.access.web === false) }; } +// Optional display metadata must not invent resource values when absent. +function metadataNumber(value) { + return typeof value === "number" && isFinite(value) && value > 0 ? value : 0; +} + +function formatBytes(bytes) { + if (!metadataNumber(bytes)) return ""; + var units = ["B", "KiB", "MiB", "GiB", "TiB"]; + var unit = 0; + while (bytes >= 1024 && unit < units.length - 1) { bytes /= 1024; unit++; } + return Math.round(bytes * 10) / 10 + " " + units[unit]; +} + +function resourceSummary(vm) { + if (!vm) return ""; + var parts = []; + if (vm.cpus) parts.push(vm.cpus + (vm.cpus === 1 ? " vCPU" : " vCPUs")); + if (vm.memoryBytes) parts.push(formatBytes(vm.memoryBytes) + " RAM"); + if (vm.diskBytes) parts.push(formatBytes(vm.diskBytes) + " disk"); + return parts.join(" · "); +} + +function networkSummary(vm) { + if (!vm) return ""; + var parts = []; + if (vm.proxyPort) parts.push("Port " + vm.proxyPort); + parts.push(vm.publicWeb ? "Public web" : "Private web"); + if (vm.egressPolicy) + parts.push("Egress: " + (vm.egressPolicy === "all" ? "all destinations" : vm.egressPolicy === "integrations" ? "integrations only" : vm.egressPolicy)); + return parts.join(" · "); +} + +function createdAgo(createdAt, now) { + if (!createdAt) return ""; + var timestamp = Date.parse(createdAt); + if (!isFinite(timestamp)) return ""; + if (typeof now !== "number" || !isFinite(now)) now = Date.now(); + var seconds = Math.max(0, (now - timestamp) / 1000); + if (seconds < 60) return "Created just now"; + var count, unit; + if (seconds < 3600) { count = Math.floor(seconds / 60); unit = "minute"; } + else if (seconds < 86400) { count = Math.floor(seconds / 3600); unit = "hour"; } + else { count = Math.floor(seconds / 86400); unit = "day"; } + return "Created " + count + " " + unit + (count === 1 ? "" : "s") + " ago"; +} + +function detailFacts(vm) { + if (!vm) return []; + var facts = []; + if (vm.region || vm.regionDisplay) + facts.push({label: "Region", value: vm.region ? vm.region.toUpperCase() : vm.regionDisplay, tooltip: vm.regionDisplay || ""}); + if (typeof vm.emailReceiveEnabled === "boolean") + facts.push({label: "Email", value: vm.emailReceiveEnabled ? "Receive on" : "Receive off"}); + return facts; +} + function parseVms(text) { var data = JSON.parse(text); if (!data || !Array.isArray(data.vms)) @@ -123,6 +190,27 @@ function sshCommand(vm) { return ["omarchy-launch-terminal", "ssh", "--", safe.sshDest]; } +// Fixed command/host and validated arguments only: SSH joins remote arguments +// into a command string, so never pass arbitrary metadata or an arbitrary verb. +function managementCommand(vm, action) { + if (["restart", "pause", "resume", "copy"].indexOf(action) < 0) + throw new Error("Unsupported VM action"); + if (!vm || vm.adminAllowed !== true) throw new Error("VM administration is not allowed"); + var safe = normalizeVm({vm_name: vm.name}); + return ["timeout", "--kill-after=2s", "120s", "ssh", + "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", + "-o", "ConnectTimeout=8", "-o", "ConnectionAttempts=1", + "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2", + "exe.dev", action === "copy" ? "cp" : action, safe.name, "--json"]; +} + +function replCommand() { + // Omarchy floats this app-id. xdg-terminal-exec adapts it to the user's + // configured terminal; no transient Hyprland rule or shell wrapper needed. + return ["omarchy-launch-terminal", "--app-id=org.omarchy.terminal", + "--title=exe.dev", "ssh", "exe.dev"]; +} + function browserCommand(vm) { var safe = normalizeVm({vm_name: vm.name}); return ["omarchy-launch-browser", safe.url]; diff --git a/README.md b/README.md index f37bf97..e7dee36 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,12 @@ An independent community plugin, not an official exe.dev or Omarchy product. - Lists VMs grouped by their first tag, sorted by name within each group. - Press `/` to fuzzy-find by VM name, with a flat, best-match-first result list. - Shows emoji, description, tags, status, and public-web visibility. -- Separate browser and SSH actions, respecting access restrictions from the API. +- Minimal VM details on Enter, with browser/SSH buttons and full connection info. +- Always-visible B/S shortcut guide; quick-launch actions respect API access restrictions. +- Click a VM to SSH by default, configurable to browser or details. +- Relative creation age beside the VM name; restart, pause/resume, and copy actions + with explicit confirmation and administration-access checks. +- E opens the exe.dev REPL in a new floating terminal. - Keyboard navigation, scrolling, and theme-aware styling. - Refreshes on open and periodically while open; retains the last successful list in memory when a query fails. @@ -24,7 +29,8 @@ An independent community plugin, not an official exe.dev or Omarchy product. Tested on Omarchy **4.0.6-1**; older Waybar-based releases are not supported. - OpenSSH (`ssh`) and GNU coreutils (`timeout`). - Omarchy's `omarchy-launch-browser` and `omarchy-launch-terminal` commands, and - your configured browser and terminal. + your configured browser and terminal. The REPL shortcut uses + `--app-id=org.omarchy.terminal`, which Omarchy's stock window rules float. - An exe.dev account and SSH authentication usable by the desktop shell. These desktop tools ship with the supported Omarchy environment. Node.js is only @@ -78,13 +84,48 @@ omarchy bar move cmk.exe-dev --section right ## Usage Click the bar's server icon to open the list. The header globe opens the exe.dev -dashboard; the refresh button reloads the VMs. Each VM has separate browser and -SSH buttons. Middle-click the bar icon to refresh without opening the panel. +dashboard; the refresh button reloads the VMs. Middle-click the bar icon to +refresh without opening the panel. + +Hover or use ↑/↓ to select a VM. **S opens SSH; B opens the browser**, directly +from either the list or details. The footer displays these shortcuts without +adding buttons to every row. + +**Enter opens details**: the VM's name, emoji and human-readable creation age +become the panel title (for example, "Created just now" or "Created 3 days ago"). +The age updates while details are open; hover for the exact timestamp. +Hostname, status, web port, sharing, and egress policy are grouped together +directly below, followed by the description and tags. A dense metadata grid +shows CPU/RAM/disk, region, email receiving and image, when returned by the API. Full image references and clipped values are available on hover. +SSH and Browser buttons retain their full destinations in tooltips. +In details, use the arrow keys to select an action and Enter to activate it, +or use S/B directly for SSH/browser. +Escape or the back arrow returns to the same filtered list and selection. + +**Clicking a VM opens SSH by default.** You can change this to browser or details +in the plugin settings; the setting affects mouse clicks only. The browser action opens `https://.exe.xyz/`. The terminal action runs SSH using the API's validated `ssh_dest`, including username-routed destinations. Neither action changes a VM's web-sharing or access settings. +### VM management + +Details also provide **Copy**, **Restart**, and **Pause** (or **Resume** for a +paused VM). These controls require the API's administration permission and ask +for confirmation naming the target VM. Enter confirms; Escape cancels without +executing a command. + +Copy uses exe.dev's auto-generated name and consumes additional VM capacity. +Restart interrupts running work and connections; pause suspends the VM until +resumed. Commands run in the background, display their outcome, and refresh the +list afterward. Only one management command runs at a time. Requests have a +120-second deadline; a timeout can leave the outcome unknown, so refresh and +check the account before retrying, especially before making another copy. + +On the main list, **E** opens a new floating terminal running `ssh exe.dev`. +It does not act on the selected VM. While typing a filter, E is ordinary text. + ### Keyboard | Key | Action | @@ -93,9 +134,13 @@ Neither action changes a VM's web-sharing or access settings. | Enter while filtering | Return focus to the filtered list without opening a VM | | Escape while filtering | Clear the filter and return focus to the list | | ↑ / ↓ or K / J | Select VM | -| ← / → or H / L | Select browser / SSH action | -| Enter / Space | Open the selected action (initially SSH) | +| Enter / Space in the list | Open details for the selected VM | +| Arrows or H / J / K / L in details | Select a launch or management action | +| Enter / Space in details | Launch the selected action | +| Escape in details | Return to the list, keeping the query and selection | | B / S | Open the selected VM in browser / SSH | +| E in the list | Open the exe.dev REPL in a floating terminal | +| Enter / Escape at a management confirmation | Confirm / cancel the named operation | | R | Refresh | | Escape in the list | Close the popout | | Tab / Shift+Tab in the list | Switch to an adjacent Omarchy panel | @@ -112,13 +157,27 @@ query. Navigation and actions always refer to the displayed results. ## Settings +Choose what happens when you click a VM: + +```sh +omarchy bar set cmk.exe-dev clickAction ssh # default +omarchy bar set cmk.exe-dev clickAction browser +omarchy bar set cmk.exe-dev clickAction details +``` + +Enter in the list always opens details, regardless of this setting. S and B +always launch SSH and browser respectively. If the API denies the chosen action, +the plugin does not launch it or silently substitute another action. + +Configure periodic refresh: + ```sh omarchy bar set cmk.exe-dev refreshIntervalSec 120 ``` The default is 60 seconds; periodic refresh is clamped to at least 15 seconds and only runs while the popout is open. There is also an initial query when the -widget loads and a refresh on opening. Overlapping queries are skipped. +widget loads and a refresh on opening. Overlapping refresh requests are coalesced. Queries have an 8-second connection timeout and a 20-second total deadline, with forced termination after a further 2 seconds if needed. A successful response @@ -170,7 +229,7 @@ sharing logs or filing an issue. ```sh node --test tests/*.test.cjs omarchy plugin validate . -qmllint -I "$OMARCHY_PATH/shell" Widget.qml VmService.qml +qmllint -I "$OMARCHY_PATH/shell" Widget.qml VmService.qml VmNavigation.qml VmDetail.qml ShortcutHint.qml ``` CI runs the dependency-free Node tests, manifest/repository contract checks, and @@ -183,9 +242,11 @@ release checklist, and [CHANGELOG.md](CHANGELOG.md) for release notes. ## Security and scope -The plugin lists VMs and launches browser/SSH actions. It does not create, delete, -stop, resize, unlock, or change sharing on VMs. Commands use argument arrays, not -a shell or the API's `ssh_command` string. VM names and SSH destinations are +The plugin lists VMs, launches browser/SSH sessions and the lobby REPL, and can +restart, pause/resume, or copy a VM after explicit confirmation. It does not +delete, resize, unlock, or change sharing on VMs. Management requires current +administration permission and uses a fixed allowlist of commands. Commands use +argument arrays, not a local shell or the API's `ssh_command` string. VM names and SSH destinations are validated before launch, and server metadata is rendered as plain text. Plugins are **not sandboxed**. These boundaries are implementation choices, not diff --git a/SECURITY.md b/SECURITY.md index daefc33..c1bb96d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,8 +31,20 @@ The plugin: - Uses argument arrays and an SSH option terminator instead of shell-evaluated VM metadata; it never evaluates the API's `ssh_command`. - Renders API-provided metadata as plain text. +- Offers restart, pause/resume, and copy only after a confirmation naming the + target VM, and only when the latest API record explicitly grants admin access. + These commands use the fixed exe.dev host and allowlisted verbs; names are + validated before remote SSH command serialization. Copy creates a VM and may + consume additional capacity; restart/pause can interrupt running work. +- Serializes management requests and bounds them to 120 seconds plus a 2-second + forced-termination grace period. A timeout does not prove the remote operation + failed: refresh before retrying, particularly before copying. +- Opens the lobby REPL on E using a new terminal with Omarchy's stock floating + app-id. Commands typed by the user in that REPL are outside the plugin's + allowlist and confirmation UI. - Does not install packages, request elevated privileges, or change SSH config, - keys, known-hosts entries, VM lifecycle, access, or sharing settings. + keys, known-hosts entries, VM access, or sharing settings. No delete action is + exposed. Opening SSH intentionally gives you an interactive session on the selected VM. Opening a website exposes the browser to that VM's content. SSH forwarding, diff --git a/ShortcutHint.qml b/ShortcutHint.qml new file mode 100644 index 0000000..8989698 --- /dev/null +++ b/ShortcutHint.qml @@ -0,0 +1,40 @@ +import QtQuick +import qs.Commons +import qs.Commons as Commons + +// A guide, not a button: no pointer handler or keyboard focus. +Row { + id: root + required property string shortcut + required property string label + property color foreground: Commons.Color.foreground + property string fontFamily: Style.font.family + spacing: Style.space(6) + + Rectangle { + width: keyText.implicitWidth + Style.space(10) + height: keyText.implicitHeight + Style.space(6) + radius: Style.space(4) + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.045) + border.width: 1 + border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.2) + + Text { + id: keyText + anchors.centerIn: parent + text: root.shortcut + textFormat: Text.PlainText + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.label + textFormat: Text.PlainText + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } +} diff --git a/VmDetail.qml b/VmDetail.qml new file mode 100644 index 0000000..0f45421 --- /dev/null +++ b/VmDetail.qml @@ -0,0 +1,339 @@ +import QtQuick +import qs.Commons +import qs.Commons as Commons +import qs.Ui as Ui +import "Model.js" as Model + +Column { + id: root + property var vm: null + property int selectedAction: 1 + property bool cursorActive: false + property color foreground: Commons.Color.foreground + property color accent: Commons.Color.accent + property string fontFamily: Style.font.family + readonly property color dim: Qt.darker(foreground, 1.5) + readonly property string resources: Model.resourceSummary(vm) + readonly property var facts: Model.detailFacts(vm) + + property bool managementBusy: false + property bool managementError: false + property string managementMessage: "" + property string pendingAction: "" + property string pendingVmName: "" + readonly property bool confirming: pendingAction !== "" && !!vm && vm.name === pendingVmName + readonly property string confirmationLabel: pendingAction === "copy" ? "Create copy" : pendingAction === "restart" ? "Restart VM" : pendingAction === "resume" ? "Resume VM" : "Pause VM" + + signal launchRequested(int action) + signal actionHovered(int action) + signal managementRequested(string vmName, string action) + signal returnFocusRequested() + + function requestManagement(action) { + if (!vm || !vm.adminAllowed || managementBusy || ["copy", "restart", "pause", "resume"].indexOf(action) < 0) return + pendingVmName = vm.name + pendingAction = action + } + + function cancelPending() { + pendingAction = "" + pendingVmName = "" + } + + function confirmPending() { + if (!confirming || !vm.adminAllowed || managementBusy) return + var name = pendingVmName + var action = pendingAction + cancelPending() + managementRequested(name, action) + } + + onConfirmingChanged: { + if (confirming) Qt.callLater(function() { if (root.confirming) confirmation.forceActiveFocus() }) + else returnFocusRequested() + } + onVmChanged: if (!vm || vm.name !== pendingVmName) cancelPending() + onVisibleChanged: if (!visible) cancelPending() + spacing: Style.space(10) + + // Compact label/value rows, with full values available on hover. + component DetailField: Item { + id: field + required property string label + required property string value + property string tooltip: "" + implicitHeight: Math.max(fieldLabel.implicitHeight, fieldValue.implicitHeight) + Text { + id: fieldLabel + width: Style.space(62) + text: field.label + textFormat: Text.PlainText + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + Text { + id: fieldValue + anchors.left: fieldLabel.right + anchors.leftMargin: Style.space(8) + anchors.right: parent.right + text: field.value + textFormat: Text.PlainText + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + } + HoverHandler { id: fieldHover } + Ui.PanelToolTip { + visible: fieldHover.hovered && (field.tooltip !== "" || fieldValue.truncated) + text: field.tooltip || field.value + fontFamily: root.fontFamily + } + } + + // The VM identity is in the panel title. Keep connection information + // together here, rather than scattering it through the machine facts. + Column { + width: parent.width + spacing: Style.space(6) + Item { + width: parent.width + implicitHeight: Math.max(host.implicitHeight, status.implicitHeight) + Text { + id: host + width: Math.max(0, parent.width - status.width - Style.space(12)) + text: root.vm ? root.vm.name + ".exe.xyz" : "" + textFormat: Text.PlainText + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + HoverHandler { id: hostHover } + Ui.PanelToolTip { + visible: hostHover.hovered && host.truncated + text: host.text + fontFamily: root.fontFamily + } + } + Text { + id: status + anchors.right: parent.right + width: Math.min(implicitWidth, parent.width * 0.3) + text: root.vm ? "● " + root.vm.status : "" + textFormat: Text.PlainText + color: root.accent + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + } + } + Text { + width: parent.width + text: Model.networkSummary(root.vm) + textFormat: Text.PlainText + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WrapAnywhere + } + } + + Text { + visible: text !== "" + width: parent.width + text: root.vm ? root.vm.comment : "" + textFormat: Text.PlainText + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WrapAnywhere + } + + Flow { + visible: !!root.vm && root.vm.tags.length > 0 + width: parent.width + spacing: Style.space(5) + Repeater { + model: root.vm ? root.vm.tags : [] + Rectangle { + required property string modelData + width: Math.min(tagText.implicitWidth + Style.space(12), parent.width) + height: tagText.implicitHeight + Style.space(6) + radius: Style.space(4) + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.055) + Text { + id: tagText + anchors.centerIn: parent + width: Math.min(implicitWidth, parent.width - Style.space(12)) + text: "#" + parent.modelData + textFormat: Text.PlainText + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + } + } + } + } + + Ui.PanelSeparator { width: parent.width; foreground: root.foreground } + + Column { + width: parent.width + spacing: Style.space(9) + DetailField { + visible: root.resources !== "" + width: parent.width + label: "Size" + value: root.resources + } + Grid { + id: factsGrid + width: parent.width + columns: root.width >= Style.space(420) ? 2 : 1 + columnSpacing: Style.space(16) + rowSpacing: Style.space(9) + Repeater { + model: root.facts + DetailField { + required property var modelData + width: (factsGrid.width - (factsGrid.columns - 1) * factsGrid.columnSpacing) / factsGrid.columns + label: modelData.label + value: modelData.value + tooltip: modelData.tooltip || "" + } + } + } + DetailField { + visible: !!root.vm && root.vm.image !== "" + width: parent.width + label: "Image" + value: root.vm ? root.vm.image : "" + } + } + + Row { + width: parent.width + spacing: Style.space(8) + Ui.Button { + width: (parent.width - parent.spacing) / 2 + height: Style.space(36) + text: "SSH · S" + iconText: "" + foreground: root.foreground + fontFamily: root.fontFamily + bordered: true + enabled: !!root.vm && root.vm.shellAllowed && !root.confirming + opacity: enabled ? 1 : 0.4 + hasCursor: root.cursorActive && root.selectedAction === 1 + tooltipText: enabled ? "SSH to " + root.vm.sshDest : "SSH access is unavailable" + onHovered: function(on) { if (on) root.actionHovered(1) } + onClicked: root.launchRequested(1) + } + Ui.Button { + width: (parent.width - parent.spacing) / 2 + height: Style.space(36) + text: "Browser · B" + iconText: "󰖟" + foreground: root.foreground + fontFamily: root.fontFamily + bordered: true + enabled: !!root.vm && root.vm.webAllowed && !root.confirming + opacity: enabled ? 1 : 0.4 + hasCursor: root.cursorActive && root.selectedAction === 0 + tooltipText: enabled ? "Open " + root.vm.url : "Web access is unavailable" + onHovered: function(on) { if (on) root.actionHovered(0) } + onClicked: root.launchRequested(0) + } + } + + Row { + visible: !root.confirming + width: parent.width + spacing: Style.space(6) + Repeater { + model: [ + {label: "Copy", action: "copy", index: 2}, + {label: "Restart", action: "restart", index: 3}, + {label: root.vm && root.vm.status === "paused" ? "Resume" : "Pause", action: root.vm && root.vm.status === "paused" ? "resume" : "pause", index: 4} + ] + Ui.Button { + required property var modelData + width: (parent.width - Style.space(12)) / 3 + height: Style.space(28) + text: modelData.label + fontSize: Style.font.bodySmall + foreground: root.foreground + fontFamily: root.fontFamily + bordered: true + enabled: !!root.vm && root.vm.adminAllowed && !root.managementBusy + opacity: enabled ? 1 : 0.4 + hasCursor: root.cursorActive && root.selectedAction === modelData.index + tooltipText: !root.vm || !root.vm.adminAllowed ? "VM administration access is required" : modelData.label + " this VM (asks for confirmation)" + onHovered: function(on) { if (on) root.actionHovered(modelData.index) } + onClicked: root.requestManagement(modelData.action) + } + } + } + + Column { + id: confirmation + visible: root.confirming + width: parent.width + spacing: Style.space(8) + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape) { + event.accepted = true + root.cancelPending() + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + event.accepted = true + root.confirmPending() + } + } + Text { + width: parent.width + text: root.pendingAction === "copy" ? "Create a copy of " + root.pendingVmName + "? This uses additional VM capacity." + : root.pendingAction === "restart" ? "Restart " + root.pendingVmName + "? Connections and running work will be interrupted." + : root.pendingAction === "resume" ? "Resume " + root.pendingVmName + "?" + : "Pause " + root.pendingVmName + "? Running work will be suspended until resumed." + textFormat: Text.PlainText + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WrapAnywhere + } + Row { + width: parent.width + spacing: Style.space(8) + Ui.Button { + width: (parent.width - parent.spacing) / 2 + text: root.confirmationLabel + bordered: true + foreground: root.foreground + fontFamily: root.fontFamily + enabled: !!root.vm && root.vm.adminAllowed && !root.managementBusy + onClicked: root.confirmPending() + } + Ui.Button { + width: (parent.width - parent.spacing) / 2 + text: "Cancel · Esc" + bordered: true + foreground: root.foreground + fontFamily: root.fontFamily + onClicked: root.cancelPending() + } + } + } + + Text { + visible: text !== "" && !root.confirming + width: parent.width + text: root.managementMessage + textFormat: Text.PlainText + color: root.managementError ? Commons.Color.urgent : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WrapAnywhere + } +} diff --git a/VmNavigation.qml b/VmNavigation.qml new file mode 100644 index 0000000..5244e60 --- /dev/null +++ b/VmNavigation.qml @@ -0,0 +1,86 @@ +import QtQml +import "Model.js" as Model + +// Pure navigation state: no desktop access or command execution. Keeping VM +// identity separate from row position prevents refreshes from retargeting actions. +QtObject { + id: root + property var vms: [] + property string query: "" + property string selectedName: "" + property string detailName: "" + property int detailAction: 1 // 0 = browser, 1 = SSH + readonly property var filteredVms: Model.filterVms(vms, query) + readonly property var filteredRows: Model.listRows(filteredVms, query) + readonly property int selectedIndex: Math.max(0, indexOf(filteredVms, selectedName)) + readonly property var selectedVm: filteredVms[selectedIndex] || null + readonly property var detailVm: vms[indexOf(vms, detailName)] || null + readonly property bool showingDetails: detailVm !== null + readonly property var currentVm: showingDetails ? detailVm : selectedVm + + signal launchRequested(var vm, int action) + signal managementRequested(string action) + + function indexOf(machines, name) { + for (var i = 0; i < machines.length; i++) { + if (machines[i].name === name) return i + } + return -1 + } + + function selectIndex(index) { + if (filteredVms[index]) selectedName = filteredVms[index].name + } + + function move(dx, dy) { + if (showingDetails) { + var actions = [1, 0, 2, 3, 4] // SSH, browser, copy, restart, pause/resume + if (dx !== 0) detailAction = actions[Math.max(0, Math.min(actions.length - 1, actions.indexOf(detailAction) + (dx > 0 ? 1 : -1)))] + if (dy !== 0) detailAction = dy > 0 ? 2 : 1 + return + } + if (dy !== 0) selectIndex(Math.max(0, Math.min(filteredVms.length - 1, selectedIndex + dy))) + } + + function showDetails(vm) { + if (!vm || indexOf(vms, vm.name) < 0) return + selectedName = vm.name + detailAction = vm.shellAllowed ? 1 : 0 + detailName = vm.name + } + + function back() { detailName = "" } + + function activateSelection() { + if (!showingDetails) showDetails(selectedVm) + else if (detailAction < 2) launchRequested(detailVm, detailAction) + else managementRequested(detailAction === 2 ? "copy" : detailAction === 3 ? "restart" : detailVm.status === "paused" ? "resume" : "pause") + } + + function shortcut(action) { + if (currentVm) launchRequested(currentVm, action) + } + + function click(vm, action) { + if (!vm) return + selectedName = vm.name + if (action === "details") showDetails(vm) + else launchRequested(vm, action === "browser" ? 0 : 1) + } + + function reset() { + // The host owns the query binding and clears the input on open. + detailName = "" + selectedName = "" + detailAction = 1 + } + + onQueryChanged: { + selectedName = "" + detailName = "" + } + onVmsChanged: { + if (detailName !== "" && indexOf(vms, detailName) < 0) detailName = "" + if (selectedName !== "" && indexOf(vms, selectedName) < 0) selectedName = "" + } +} diff --git a/VmService.qml b/VmService.qml index 4817836..241b7c4 100644 --- a/VmService.qml +++ b/VmService.qml @@ -9,14 +9,58 @@ Item { property string updatedAt: "" property bool active: false property int refreshIntervalSec: 60 + property bool refreshPending: false readonly property bool busy: listProcess.running readonly property var rows: Model.groupedRows(vms) + readonly property bool managementBusy: managementProcess.running + property string managementVmName: "" + property string managementAction: "" + property string managementMessage: "" + property bool managementError: false function refresh() { - if (listProcess.running) return + if (listProcess.running) { refreshPending = true; return } + refreshPending = false listProcess.running = true } + function manage(vmName, action) { + if (managementProcess.running) return + // Look up the latest record: never act on an old detail-view snapshot. + var vm = null + for (var i = 0; i < vms.length; i++) { + if (vms[i].name === vmName) { vm = vms[i]; break } + } + managementVmName = vmName + managementAction = action + managementError = false + try { + managementProcess.command = Model.managementCommand(vm, action) + managementMessage = action === "copy" ? "Creating a copy…" : action === "pause" ? "Pausing…" : action === "resume" ? "Resuming…" : "Restarting…" + managementProcess.running = true + } catch (error) { + managementMessage = error.message + managementError = true + } + } + + function readList(exitCode) { + if (exitCode !== 0) { + lastError = exitCode === 124 || exitCode === 137 + ? "exe.dev timed out. Check your connection and retry." + : (errors.text.trim() || output.text.trim() || "Could not list VMs (exit " + exitCode + ").") + return + } + try { + var next = Model.parseVms(output.text) + vms = next + updatedAt = Qt.formatTime(new Date(), "HH:mm:ss") + lastError = "" + } catch (error) { + lastError = "Could not read exe.dev response: " + error.message + } + } + Component.onCompleted: refresh() Timer { @@ -28,8 +72,6 @@ Item { Process { id: listProcess - // No passwords or host-key prompts in the desktop shell. A hard deadline - // also bounds hangs after connecting (ConnectTimeout alone cannot). command: ["timeout", "--kill-after=2s", "20s", "ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=8", "-o", "ConnectionAttempts=1", @@ -38,20 +80,42 @@ Item { stdout: StdioCollector { id: output; waitForEnd: true } stderr: StdioCollector { id: errors; waitForEnd: true } onExited: function(exitCode) { - if (exitCode !== 0) { - root.lastError = exitCode === 124 || exitCode === 137 - ? "exe.dev timed out. Check your connection and retry." - : (errors.text.trim() || output.text.trim() || "Could not list VMs (exit " + exitCode + ").") - return - } - try { - var next = Model.parseVms(output.text) - root.vms = next - root.updatedAt = Qt.formatTime(new Date(), "HH:mm:ss") - root.lastError = "" - } catch (error) { - root.lastError = "Could not read exe.dev response: " + error.message + root.readList(exitCode) + if (root.refreshPending) Qt.callLater(root.refresh) + } + } + + Process { + id: managementProcess + stdout: StdioCollector { id: managementOutput; waitForEnd: true } + stderr: StdioCollector { id: managementErrors; waitForEnd: true } + onExited: function(exitCode) { + root.managementError = exitCode !== 0 + if (exitCode === 124 || exitCode === 137) { + root.managementMessage = "Request timed out; its outcome may be unknown. Refresh before retrying." + } else if (exitCode !== 0) { + root.managementMessage = (managementErrors.text.trim() || managementOutput.text.trim() || "exe.dev command failed (exit " + exitCode + ").").slice(0, 500) + } else { + root.managementMessage = root.managementAction === "copy" ? "Copy created. Refreshing machines…" + : root.managementAction === "pause" ? "VM paused." + : root.managementAction === "resume" ? "VM resumed." : "Restart requested." + // Some API errors may be returned as a JSON object. Never hide those. + try { + var response = JSON.parse(managementOutput.text) + if (response && typeof response.error === "string" && response.error !== "") { + root.managementError = true + root.managementMessage = response.error.slice(0, 500) + } else if (root.managementAction === "copy" && response && typeof response.vm_name === "string") { + var copied = Model.normalizeVm({vm_name: response.vm_name}) + root.managementMessage = "Created " + copied.name + "." + } + } catch (error) { + // Exit status is authoritative when no parseable response is returned. + // Do not invite a duplicate copy by reporting a successful command as failed. + } } + // Queue a fresh query even if an earlier list request is still running. + Qt.callLater(root.refresh) } } } diff --git a/Widget.qml b/Widget.qml index f44a84e..166d8d4 100644 --- a/Widget.qml +++ b/Widget.qml @@ -4,10 +4,10 @@ import Quickshell import Quickshell.Io import qs.Commons import qs.Commons as Commons -import qs.Ui +import qs.Ui as Ui import "Model.js" as Model -Panel { +Ui.Panel { id: root moduleName: "cmk.exe-dev" ipcTarget: "cmk.exe-dev" @@ -19,27 +19,25 @@ Panel { readonly property color dim: Qt.darker(foreground, 1.5) readonly property color urgent: Commons.Color.urgent readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family - property int selectedIndex: 0 - property int selectedAction: 1 // 0 = browser, 1 = SSH + readonly property string clickAction: String(root.setting("clickAction", "ssh")) property bool cursorActive: false - readonly property var filteredVms: Model.filterVms(service.vms, filterField.text) - readonly property var filteredRows: Model.listRows(filteredVms, filterField.text) + property real savedListContentY: 0 + property double now: Date.now() - onFilteredVmsChanged: { - selectedIndex = 0 - list.contentY = 0 + Timer { + interval: 15000 + repeat: true + running: root.opened && navigation.showingDetails + onTriggered: root.now = Date.now() } - function focusFilter() { - cursorActive = false - filterField.forceActiveFocus() - filterField.selectAll() - } - - function focusList() { - keys.forceActiveFocus() - cursorActive = filteredVms.length > 0 - Qt.callLater(scrollToSelection) + function openRepl() { + try { + Quickshell.execDetached(Model.replCommand()) + root.close() + } catch (error) { + service.lastError = "Could not open exe.dev: " + error.message + } } function activate(vm, action) { @@ -52,17 +50,31 @@ Panel { } } - function moveCursor(dx, dy) { - cursorActive = true - selectedIndex = Math.max(0, Math.min(filteredVms.length - 1, selectedIndex + dy)) - if (dx !== 0) selectedAction = dx > 0 ? 1 : 0 - scrollToSelection() + function focusFilter() { + navigation.back() + cursorActive = false + filterField.forceActiveFocus() + filterField.selectAll() + } + + function focusList() { + keys.forceActiveFocus() + cursorActive = navigation.filteredVms.length > 0 + Qt.callLater(scrollToSelection) + } + + function backOrClose() { + if (navigation.showingDetails) { + navigation.back() + root.focusList() + } else root.close() } function scrollToSelection() { + if (navigation.showingDetails) return for (var i = 0; i < rows.count; i++) { var item = rows.itemAt(i) - if (item && item.modelData.vmIndex === selectedIndex) { + if (item && item.modelData.vmIndex === navigation.selectedIndex) { var y = item.y if (y < list.contentY) list.contentY = y else if (y + item.height > list.contentY + list.height) @@ -72,9 +84,11 @@ Panel { } } - onOpenedChanged: if (opened) { + onOpenedChanged: if (root.opened) { + navigation.reset() filterField.text = "" cursorActive = false + savedListContentY = 0 list.contentY = 0 service.refresh() } @@ -85,6 +99,27 @@ Panel { refreshIntervalSec: Number(root.setting("refreshIntervalSec", 60)) || 60 } + VmNavigation { + id: navigation + vms: service.vms + query: filterField.text + onLaunchRequested: function(vm, action) { root.activate(vm, action) } + onManagementRequested: function(action) { detailView.requestManagement(action) } + onQueryChanged: list.contentY = 0 + onShowingDetailsChanged: { + if (showingDetails) { + root.now = Date.now() + root.savedListContentY = list.contentY + list.contentY = 0 + } else Qt.callLater(function() { + if (!navigation.showingDetails) { + list.contentY = Math.max(0, Math.min(root.savedListContentY, list.contentHeight - list.height)) + root.scrollToSelection() + } + }) + } + } + IpcHandler { target: root.ipcTarget function open(): void { root.open() } @@ -95,12 +130,15 @@ Panel { return JSON.stringify({count: service.vms.length, busy: service.busy, updatedAt: service.updatedAt, error: service.lastError, opened: root.opened, filter: filterField.text, filterFocused: filterField.activeFocus, - filteredCount: root.filteredVms.length, - selectedVm: root.filteredVms[root.selectedIndex] ? root.filteredVms[root.selectedIndex].name : null}) + filteredCount: navigation.filteredVms.length, + selectedVm: navigation.selectedVm ? navigation.selectedVm.name : null, + view: navigation.showingDetails ? "details" : "list", + detailVm: navigation.detailVm ? navigation.detailVm.name : null, + clickAction: root.clickAction, managementBusy: service.managementBusy}) } } - BarIconButton { + Ui.BarIconButton { id: button anchors.fill: parent bar: root.bar @@ -113,150 +151,193 @@ Panel { } } - KeyboardPanel { + Ui.KeyboardPanel { id: panel anchorItem: button owner: root bar: root.bar open: root.opened focusTarget: keys - contentWidth: fittedContentWidth(Style.space(520)) - contentHeight: fittedContentHeight(top.implicitHeight + vmColumn.implicitHeight + bottom.implicitHeight + Style.space(24), Style.space(900)) + contentWidth: panel.fittedContentWidth(Style.space(520)) + contentHeight: panel.fittedContentHeight(headerColumn.implicitHeight + bodyContent.implicitHeight + footer.implicitHeight + Style.space(24), Style.space(900)) - PanelKeyCatcher { + Ui.PanelKeyCatcher { id: keys anchors.fill: parent - blocked: filterField.activeFocus - onCloseRequested: root.close() - onMoveRequested: function(dx, dy) { root.moveCursor(dx, dy) } - onActivateRequested: root.activate(root.filteredVms[root.selectedIndex], root.selectedAction) + blocked: filterField.activeFocus || detailView.confirming + onCloseRequested: root.backOrClose() + onMoveRequested: function(dx, dy) { + root.cursorActive = true + navigation.move(dx, dy) + root.scrollToSelection() + } + onActivateRequested: { + root.cursorActive = true + navigation.activateSelection() + } onTabRequested: function(direction) { root.switchPanel(direction) } onTextKey: function(text) { if (text === "/") root.focusFilter() else if (text.toLowerCase() === "r") service.refresh() - else if (text.toLowerCase() === "b") root.activate(root.filteredVms[root.selectedIndex], 0) - else if (text.toLowerCase() === "s") root.activate(root.filteredVms[root.selectedIndex], 1) + else if (text.toLowerCase() === "b") navigation.shortcut(0) + else if (text.toLowerCase() === "s") navigation.shortcut(1) + else if (text.toLowerCase() === "e" && !navigation.showingDetails) root.openRepl() } Column { - id: content width: parent.width spacing: Style.space(12) Column { - id: top + id: headerColumn width: parent.width spacing: Style.space(12) - Item { - width: parent.width - implicitHeight: header.implicitHeight - Column { - id: header - width: Math.max(0, parent.width - headerActions.width - Style.space(12)) - spacing: Style.space(4) - Text { - text: "exe.dev" - textFormat: Text.PlainText - color: root.foreground - font.family: root.fontFamily - font.pixelSize: Style.font.title - font.bold: true - } - Text { - text: service.busy ? "Refreshing machines…" : service.vms.length + " virtual machines" - textFormat: Text.PlainText - color: root.dim - font.family: root.fontFamily - font.pixelSize: Style.font.bodySmall - } - } - Row { - id: headerActions - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: Style.space(6) - PanelActionButton { - iconText: "󰖟" - tooltipText: "Open exe.dev dashboard" - bordered: true + Item { + width: parent.width + implicitHeight: Math.max(header.implicitHeight, headerActions.implicitHeight, backButton.visible ? backButton.implicitHeight : 0) + Ui.PanelActionButton { + id: backButton + visible: navigation.showingDetails + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + iconText: "←" + tooltipText: "Back to machines (Esc)" foreground: root.foreground - onClicked: { Quickshell.execDetached(["omarchy-launch-browser", "https://exe.dev/"]); root.close() } + size: Style.space(28) + onClicked: root.backOrClose() } - PanelActionButton { - iconText: "󰑓" - tooltipText: "Refresh VMs (R)" - bordered: true - enabled: !service.busy - foreground: root.foreground - onClicked: service.refresh() + Column { + id: header + anchors.verticalCenter: parent.verticalCenter + x: backButton.visible ? backButton.width + Style.space(8) : 0 + width: Math.max(0, parent.width - x - headerActions.width - Style.space(12)) + spacing: Style.space(4) + Item { + width: parent.width + implicitHeight: Math.max(panelTitle.implicitHeight, createdAge.implicitHeight) + Text { + id: panelTitle + width: Math.max(0, Math.min(implicitWidth, parent.width - (createdAge.visible ? createdAge.width + Style.space(10) : 0))) + text: navigation.detailVm ? navigation.detailVm.emoji + " " + navigation.detailVm.name : "exe.dev" + textFormat: Text.PlainText + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.title + font.bold: true + elide: Text.ElideRight + HoverHandler { id: titleHover } + Ui.PanelToolTip { + visible: titleHover.hovered && panelTitle.truncated + text: panelTitle.text + fontFamily: root.fontFamily + } + } + Text { + id: createdAge + visible: text !== "" + anchors.left: panelTitle.right + anchors.leftMargin: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + width: Math.min(implicitWidth, parent.width * 0.48) + text: navigation.detailVm ? Model.createdAgo(navigation.detailVm.createdAt, root.now) : "" + textFormat: Text.PlainText + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + HoverHandler { id: createdHover } + Ui.PanelToolTip { + visible: createdHover.hovered && !!navigation.detailVm + text: createdAge.text + "\n" + (navigation.detailVm ? navigation.detailVm.createdAt : "") + fontFamily: root.fontFamily + } + } + } + Text { + visible: !navigation.showingDetails + text: service.busy ? "Refreshing machines…" : service.vms.length + " virtual machines" + textFormat: Text.PlainText + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + } + Row { + id: headerActions + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(6) + Ui.PanelActionButton { + iconText: "󰖟" + tooltipText: "Open exe.dev dashboard" + bordered: true + foreground: root.foreground + onClicked: { Quickshell.execDetached(["omarchy-launch-browser", "https://exe.dev/"]); root.close() } + } + Ui.PanelActionButton { + iconText: "󰑓" + tooltipText: "Refresh VMs (R)" + bordered: true + enabled: !service.busy + foreground: root.foreground + onClicked: service.refresh() + } } } - } - PanelSeparator { foreground: root.foreground } + Ui.PanelSeparator { foreground: root.foreground } - TextField { - id: filterField - width: parent.width - placeholderText: "Fuzzy find VM name (/)" - foreground: root.foreground - font.family: root.fontFamily - // Consume Enter here: returning to list focus must not also launch a VM. - Keys.onPressed: function(event) { - if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - root.focusList() - event.accepted = true - } else if (event.key === Qt.Key_Escape) { - text = "" - root.focusList() - event.accepted = true + Ui.TextField { + id: filterField + visible: !navigation.showingDetails + width: parent.width + placeholderText: "Fuzzy find VM name (/)" + foreground: root.foreground + font.family: root.fontFamily + // Consume Enter here: returning to the list must not open details. + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.focusList() + event.accepted = true + } else if (event.key === Qt.Key_Escape) { + text = "" + root.focusList() + event.accepted = true + } } } - } - - Text { - width: parent.width - visible: service.vms.length > 0 && root.filteredVms.length === 0 - text: "No VMs match this name." - textFormat: Text.PlainText - color: root.dim - font.family: root.fontFamily - font.pixelSize: Style.font.body - } - - Text { - width: parent.width - visible: service.lastError !== "" - text: service.lastError.slice(0, 500) + (service.vms.length ? "\nShowing the last successful list." : "\nRun ssh exe.dev in a terminal to check authentication, then refresh.") - textFormat: Text.PlainText - color: root.urgent - wrapMode: Text.Wrap - font.family: root.fontFamily - font.pixelSize: Style.font.bodySmall - } - Text { - visible: service.vms.length === 0 && service.lastError === "" - width: parent.width - text: service.busy ? "Connecting over SSH…" : "No VMs yet. Create one at exe.dev." - textFormat: Text.PlainText - color: root.dim - font.family: root.fontFamily - font.pixelSize: Style.font.body - wrapMode: Text.Wrap - } + Text { + width: parent.width + visible: service.lastError !== "" + text: service.lastError.slice(0, 500) + (service.vms.length ? "\nShowing the last successful list." : "\nRun ssh exe.dev in a terminal to check authentication, then refresh.") + textFormat: Text.PlainText + color: root.urgent + wrapMode: Text.Wrap + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + Text { + visible: !navigation.showingDetails && navigation.filteredVms.length === 0 + width: parent.width + text: service.vms.length > 0 ? "No VMs match this name." : (service.busy ? "Connecting over SSH…" : "No VMs yet. Create one at exe.dev.") + textFormat: Text.PlainText + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + wrapMode: Text.Wrap + } } Flickable { id: list width: parent.width - // Header/error/footer remain pinned; only the VM list scrolls. - height: Math.max(0, Math.min(vmColumn.implicitHeight, - keys.height - top.implicitHeight - bottom.implicitHeight - Style.space(24))) + height: Math.max(0, Math.min(bodyContent.implicitHeight, + keys.height - headerColumn.implicitHeight - footer.implicitHeight - Style.space(24))) contentWidth: width - contentHeight: vmColumn.implicitHeight + contentHeight: bodyContent.implicitHeight clip: true boundsBehavior: Flickable.StopAtBounds flickableDirection: Flickable.VerticalFlick @@ -264,137 +345,170 @@ Panel { ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } Column { - id: vmColumn + id: bodyContent width: list.width - spacing: Style.space(4) - Repeater { - id: rows - model: root.filteredRows - delegate: Item { - id: row - required property var modelData - readonly property var vm: modelData.vm - width: vmColumn.width - implicitHeight: modelData.kind === "group" ? Style.space(32) : Style.space(76) - - PanelSectionHeader { - visible: row.modelData.kind === "group" - anchors.left: parent.left - anchors.leftMargin: Style.space(8) - anchors.verticalCenter: parent.verticalCenter - width: parent.width - Style.space(16) - text: (row.modelData.label === "Untagged" ? "UNTAGGED" : "#" + row.modelData.label.toUpperCase()) + " · " + row.modelData.count - elide: Text.ElideRight - foreground: root.foreground - fontFamily: root.fontFamily - } - CursorSurface { - anchors.fill: parent - visible: row.vm !== null - bordered: true - foreground: root.foreground - hasCursor: root.cursorActive && root.selectedIndex === row.modelData.vmIndex + Column { + visible: !navigation.showingDetails + width: parent.width + spacing: Style.space(4) + Repeater { + id: rows + model: navigation.filteredRows + delegate: Item { + id: row + required property var modelData + readonly property var vm: modelData.vm + width: bodyContent.width + implicitHeight: modelData.kind === "group" ? Style.space(32) : Style.space(68) - Text { + Ui.PanelSectionHeader { + visible: row.modelData.kind === "group" anchors.left: parent.left - anchors.leftMargin: Style.space(10) + anchors.leftMargin: Style.space(8) anchors.verticalCenter: parent.verticalCenter - text: row.vm ? row.vm.emoji : "" - textFormat: Text.PlainText - font.pixelSize: Style.font.title - width: Style.space(28) - horizontalAlignment: Text.AlignHCenter + width: parent.width - Style.space(16) + text: (row.modelData.label === "Untagged" ? "UNTAGGED" : "#" + row.modelData.label.toUpperCase()) + " · " + row.modelData.count + elide: Text.ElideRight + foreground: root.foreground + fontFamily: root.fontFamily } - Column { - anchors.left: parent.left - anchors.leftMargin: Style.space(48) - anchors.right: actions.left - anchors.rightMargin: Style.space(10) - anchors.verticalCenter: parent.verticalCenter - spacing: Style.space(3) - Text { - width: parent.width - text: row.vm ? row.vm.name : "" - textFormat: Text.PlainText - color: root.foreground - font.family: root.fontFamily - font.pixelSize: Style.font.body - font.bold: true - elide: Text.ElideRight - } + Ui.CursorSurface { + anchors.fill: parent + visible: row.vm !== null + bordered: true + foreground: root.foreground + hasCursor: root.cursorActive && navigation.selectedIndex === row.modelData.vmIndex + Text { - width: parent.width - visible: text !== "" - text: row.vm ? row.vm.comment : "" + anchors.left: parent.left + anchors.leftMargin: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + text: row.vm ? row.vm.emoji : "" textFormat: Text.PlainText - color: root.dim - font.family: root.fontFamily - font.pixelSize: Style.font.bodySmall - elide: Text.ElideRight + font.pixelSize: Style.font.title + width: Style.space(28) + horizontalAlignment: Text.AlignHCenter } - Text { - width: parent.width - text: row.vm ? (row.vm.publicWeb ? "Public web · " : "") + row.vm.status + (row.vm.tags.length ? " · #" + row.vm.tags.join(" #") : "") : "" - textFormat: Text.PlainText - color: root.dim - font.family: root.fontFamily - font.pixelSize: Style.font.caption - elide: Text.ElideRight + Column { + anchors.left: parent.left + anchors.leftMargin: Style.space(48) + anchors.right: parent.right + anchors.rightMargin: Style.space(14) + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(5) + Item { + width: parent.width + implicitHeight: vmName.implicitHeight + Text { + id: vmName + width: Math.max(0, parent.width - vmStatus.width - Style.space(12)) + text: row.vm ? row.vm.name : "" + textFormat: Text.PlainText + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: true + elide: Text.ElideRight + } + Text { + id: vmStatus + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: Math.min(implicitWidth, parent.width * 0.28) + text: row.vm ? row.vm.status : "" + textFormat: Text.PlainText + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + } + } + Text { + width: parent.width + visible: text !== "" + text: row.vm ? row.vm.comment : "" + textFormat: Text.PlainText + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + } } - } - Row { - id: actions - anchors.right: parent.right - anchors.rightMargin: Style.space(10) - anchors.verticalCenter: parent.verticalCenter - spacing: Style.space(6) - PanelActionButton { - iconText: "󰖟" - tooltipText: row.vm ? "Open " + row.vm.url : "" - bordered: true - enabled: !!row.vm && row.vm.webAllowed - foreground: root.foreground - hasCursor: root.cursorActive && root.selectedIndex === row.modelData.vmIndex && root.selectedAction === 0 - onHovered: function(on) { - if (on) { root.cursorActive = true; root.selectedIndex = row.modelData.vmIndex; root.selectedAction = 0 } + MouseArea { + anchors.fill: parent + enabled: row.vm !== null + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: { + if (!filterField.activeFocus) { + root.cursorActive = true + navigation.selectIndex(row.modelData.vmIndex) + } } - onClicked: root.activate(row.vm, 0) - } - PanelActionButton { - iconText: "" - tooltipText: row.vm ? "SSH to " + row.vm.sshDest : "" - bordered: true - enabled: !!row.vm && row.vm.shellAllowed - foreground: root.foreground - hasCursor: root.cursorActive && root.selectedIndex === row.modelData.vmIndex && root.selectedAction === 1 - onHovered: function(on) { - if (on) { root.cursorActive = true; root.selectedIndex = row.modelData.vmIndex; root.selectedAction = 1 } + onClicked: { + keys.forceActiveFocus() + root.cursorActive = true + navigation.click(row.vm, root.clickAction) } - onClicked: root.activate(row.vm, 1) } } } } } + + VmDetail { + id: detailView + visible: navigation.showingDetails + width: parent.width + vm: navigation.detailVm + foreground: root.foreground + fontFamily: root.fontFamily + selectedAction: navigation.detailAction + cursorActive: root.cursorActive + managementBusy: service.managementBusy + managementError: service.managementError + managementMessage: vm && vm.name === service.managementVmName ? service.managementMessage : "" + onManagementRequested: function(vmName, action) { service.manage(vmName, action) } + onReturnFocusRequested: if (root.opened) keys.forceActiveFocus() + onLaunchRequested: function(action) { root.activate(navigation.detailVm, action) } + onActionHovered: function(action) { + root.cursorActive = true + navigation.detailAction = action + } + } } } Column { - id: bottom + id: footer width: parent.width - spacing: Style.space(12) - PanelSeparator { foreground: root.foreground } - Text { - width: parent.width - text: (filterField.activeFocus ? "Enter return to list · Esc clear filter" : "↑↓ select · ←→ action · Enter open · / filter · R refresh") + (service.updatedAt ? "\nUpdated " + service.updatedAt : "") - textFormat: Text.PlainText - color: root.dim - font.family: root.fontFamily - font.pixelSize: Style.font.caption - } + spacing: Style.space(10) + Ui.PanelSeparator { foreground: root.foreground } + Flow { + width: parent.width + spacing: Style.space(14) + visible: !navigation.showingDetails + opacity: filterField.activeFocus ? 0.45 : 1 + ShortcutHint { shortcut: "S"; label: "SSH"; foreground: root.foreground; fontFamily: root.fontFamily } + ShortcutHint { shortcut: "B"; label: "Browser"; foreground: root.foreground; fontFamily: root.fontFamily } + ShortcutHint { shortcut: "Enter"; label: "Details"; foreground: root.foreground; fontFamily: root.fontFamily } + ShortcutHint { shortcut: "/"; label: "Filter"; foreground: root.foreground; fontFamily: root.fontFamily } + ShortcutHint { shortcut: "E"; label: "exe.dev"; foreground: root.foreground; fontFamily: root.fontFamily } + } + Text { + width: parent.width + text: detailView.confirming ? "Enter confirm · Esc cancel" : filterField.activeFocus ? "Enter return to list · Esc clear filter" : (navigation.showingDetails + ? "←→ / ↑↓ action · Enter select · Esc back to list" + : "↑↓ select · Click " + (root.clickAction === "browser" ? "browser" : root.clickAction === "details" ? "details" : "SSH") + " · R refresh · Esc close") + textFormat: Text.PlainText + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + wrapMode: Text.Wrap + } } } } diff --git a/manifest.json b/manifest.json index d0382d5..c9b66cd 100644 --- a/manifest.json +++ b/manifest.json @@ -18,9 +18,18 @@ "allowMultiple": false, "defaultSection": "right", "defaults": { - "refreshIntervalSec": 60 + "refreshIntervalSec": 60, + "clickAction": "ssh" }, "schema": [ + { + "key": "clickAction", + "type": "enum", + "label": "Click a VM to open", + "options": ["ssh", "browser", "details"], + "defaultValue": "ssh", + "description": "Mouse click action. Enter always opens VM details from the list; B and S always launch browser and SSH." + }, { "key": "refreshIntervalSec", "type": "integer", diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs new file mode 100644 index 0000000..5135b23 --- /dev/null +++ b/tests/commands.test.cjs @@ -0,0 +1,54 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const test = require('node:test'); +const model = {}; +vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../Model.js'), 'utf8') + .replace(/^\.pragma library\s*/, ''), model); +const plain = value => JSON.parse(JSON.stringify(value)); + +test('creation age uses human units and handles missing or future timestamps', () => { + const now = Date.parse('2026-10-24T12:00:00Z'); + const age = seconds => model.createdAgo(new Date(now - seconds * 1000).toISOString(), now); + assert.equal(age(0), 'Created just now'); + assert.equal(age(59), 'Created just now'); + assert.equal(age(60), 'Created 1 minute ago'); + assert.equal(age(120), 'Created 2 minutes ago'); + assert.equal(age(3600), 'Created 1 hour ago'); + assert.equal(age(7200), 'Created 2 hours ago'); + assert.equal(age(86400), 'Created 1 day ago'); + assert.equal(age(86400 * 10), 'Created 10 days ago'); + assert.equal(age(-60), 'Created just now'); + assert.equal(model.createdAgo('', now), ''); + assert.equal(model.createdAgo('invalid date', now), ''); +}); + +test('management requires explicit administration access and allowlisted verbs', () => { + for (const access of [undefined, {}, {admin: false}, {admin: 'true'}]) { + const record = model.normalizeVm({vm_name: 'demo', access}); + assert.equal(record.adminAllowed, false); + assert.throws(() => model.managementCommand(record, 'restart'), /not allowed/); + } + const record = model.normalizeVm({vm_name: 'demo', access: {admin: true}}); + for (const action of ['restart', 'pause', 'resume', 'copy']) { + const command = plain(model.managementCommand(record, action)); + assert.deepEqual(command.slice(-4), ['exe.dev', action === 'copy' ? 'cp' : action, 'demo', '--json']); + assert.deepEqual(command.slice(0, 4), ['timeout', '--kill-after=2s', '120s', 'ssh']); + assert.ok(command.includes('BatchMode=yes')); + assert.ok(command.includes('StrictHostKeyChecking=yes')); + } + for (const action of ['rm', 'delete', 'restart;evil', 'cp', '', null]) { + assert.throws(() => model.managementCommand(record, action), /Unsupported/); + } + for (const name of ['-option', 'demo;evil', 'demo name', 'demo\nevil']) { + assert.throws(() => model.managementCommand({name, adminAllowed: true}, 'copy'), /Invalid VM name/); + } +}); + +test('REPL opens a new terminal with the stock floating app-id and fixed SSH host', () => { + assert.deepEqual(plain(model.replCommand()), [ + 'omarchy-launch-terminal', '--app-id=org.omarchy.terminal', + '--title=exe.dev', 'ssh', 'exe.dev', + ]); +}); diff --git a/tests/manifest.test.cjs b/tests/manifest.test.cjs index 035a3ca..6c5aaf2 100644 --- a/tests/manifest.test.cjs +++ b/tests/manifest.test.cjs @@ -30,7 +30,13 @@ test('manifest declares a complete, installable bar widget', () => { const widget = read(manifest.entryPoints.barWidget); assert.ok(widget.includes('moduleName: "' + manifest.id + '"')); assert.ok(widget.includes('ipcTarget: "' + manifest.id + '"')); - assert.ok(widget.includes('Model.listRows(filteredVms, filterField.text)')); + assert.ok(widget.includes('VmNavigation {')); + assert.ok(read('VmNavigation.qml').includes('Model.listRows(filteredVms, query)')); + assert.equal(manifest.barWidget.defaults.clickAction, 'ssh'); + const clickSetting = manifest.barWidget.schema.find(field => field.key === 'clickAction'); + assert.equal(clickSetting.type, 'enum'); + assert.deepEqual(clickSetting.options, ['ssh', 'browser', 'details']); + assert.equal(clickSetting.defaultValue, 'ssh'); }); test('plugin tree contains no symlinks outside Git internals', () => { From 4d92755fd6eb11fc46a2865359a8b36d104a35cd Mon Sep 17 00:00:00 2001 From: Christian Kreiling Date: Fri, 23 Oct 2026 19:14:36 -0700 Subject: [PATCH 2/3] Harden confirmation and reconcile uncertain management outcomes --- CHANGELOG.md | 2 +- Model.js | 11 ++++ README.md | 6 ++- SECURITY.md | 6 ++- VmDetail.qml | 2 + VmService.qml | 17 ++++--- tests/management-safety.test.cjs | 87 ++++++++++++++++++++++++++++++++ 7 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 tests/management-safety.test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6072b8f..82dd604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.0.0 -Initial release (in preparation). +Initial public release. - Compact VM details on Enter, with a shared hostname, resource summary, metadata grid, and browser/SSH buttons; no duplicate SSH/web address blocks. diff --git a/Model.js b/Model.js index fd10f92..365f490 100644 --- a/Model.js +++ b/Model.js @@ -192,6 +192,17 @@ function sshCommand(vm) { // Fixed command/host and validated arguments only: SSH joins remote arguments // into a command string, so never pass arbitrary metadata or an arbitrary verb. +function managementFailure(exitCode, detail) { + var uncertain = exitCode === 124 || exitCode >= 128 || exitCode < 0; + var message = ""; + if (uncertain) { + message = (exitCode === 124 || exitCode === 137 ? "Request timed out" : "Connection lost or command interrupted") + + "; the operation may have completed remotely. Refresh and check the VM inventory before retrying, especially before making another copy."; + } + if (detail) message += (message ? "\n" : "") + String(detail); + return {uncertain: uncertain, message: (message || "exe.dev command failed (exit " + exitCode + ").").slice(0, 500)}; +} + function managementCommand(vm, action) { if (["restart", "pause", "resume", "copy"].indexOf(action) < 0) throw new Error("Unsupported VM action"); diff --git a/README.md b/README.md index e7dee36..a121fa2 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,10 @@ Copy uses exe.dev's auto-generated name and consumes additional VM capacity. Restart interrupts running work and connections; pause suspends the VM until resumed. Commands run in the background, display their outcome, and refresh the list afterward. Only one management command runs at a time. Requests have a -120-second deadline; a timeout can leave the outcome unknown, so refresh and -check the account before retrying, especially before making another copy. +120-second deadline. Timeouts or SSH disconnects can leave the outcome unknown: +management stays disabled until a subsequent inventory refresh succeeds. Check +the refreshed inventory before retrying, especially before making another copy. +Confirmation ignores held-key repeats and requires a fresh Enter press. On the main list, **E** opens a new floating terminal running `ssh exe.dev`. It does not act on the selected VM. While typing a filter, E is ordinary text. diff --git a/SECURITY.md b/SECURITY.md index c1bb96d..9452d5c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -37,8 +37,10 @@ The plugin: validated before remote SSH command serialization. Copy creates a VM and may consume additional capacity; restart/pause can interrupt running work. - Serializes management requests and bounds them to 120 seconds plus a 2-second - forced-termination grace period. A timeout does not prove the remote operation - failed: refresh before retrying, particularly before copying. + forced-termination grace period. A timeout or SSH disconnect does not prove + the remote operation failed: management remains disabled until an inventory + query started afterward succeeds. Check that inventory before retrying, + particularly before copying. Confirmation rejects held-key autorepeat. - Opens the lobby REPL on E using a new terminal with Omarchy's stock floating app-id. Commands typed by the user in that REPL are outside the plugin's allowlist and confirmation UI. diff --git a/VmDetail.qml b/VmDetail.qml index 0f45421..504c7c0 100644 --- a/VmDetail.qml +++ b/VmDetail.qml @@ -283,6 +283,8 @@ Column { width: parent.width spacing: Style.space(8) Keys.onPressed: function(event) { + // Opening a prompt must require a new deliberate press, not held Enter. + if (event.isAutoRepeat) { event.accepted = true; return } if (event.key === Qt.Key_Escape) { event.accepted = true root.cancelPending() diff --git a/VmService.qml b/VmService.qml index 241b7c4..3b16d94 100644 --- a/VmService.qml +++ b/VmService.qml @@ -12,7 +12,9 @@ Item { property bool refreshPending: false readonly property bool busy: listProcess.running readonly property var rows: Model.groupedRows(vms) - readonly property bool managementBusy: managementProcess.running + property bool managementAwaitingRefresh: false + property bool listReconcilesManagement: false + readonly property bool managementBusy: managementProcess.running || managementAwaitingRefresh property string managementVmName: "" property string managementAction: "" property string managementMessage: "" @@ -21,11 +23,13 @@ Item { function refresh() { if (listProcess.running) { refreshPending = true; return } refreshPending = false + // Only a query started after an uncertain command can reconcile its outcome. + listReconcilesManagement = managementAwaitingRefresh listProcess.running = true } function manage(vmName, action) { - if (managementProcess.running) return + if (managementBusy) return // Look up the latest record: never act on an old detail-view snapshot. var vm = null for (var i = 0; i < vms.length; i++) { @@ -81,6 +85,7 @@ Item { stderr: StdioCollector { id: errors; waitForEnd: true } onExited: function(exitCode) { root.readList(exitCode) + if (root.listReconcilesManagement && root.lastError === "") root.managementAwaitingRefresh = false if (root.refreshPending) Qt.callLater(root.refresh) } } @@ -91,10 +96,10 @@ Item { stderr: StdioCollector { id: managementErrors; waitForEnd: true } onExited: function(exitCode) { root.managementError = exitCode !== 0 - if (exitCode === 124 || exitCode === 137) { - root.managementMessage = "Request timed out; its outcome may be unknown. Refresh before retrying." - } else if (exitCode !== 0) { - root.managementMessage = (managementErrors.text.trim() || managementOutput.text.trim() || "exe.dev command failed (exit " + exitCode + ").").slice(0, 500) + if (exitCode !== 0) { + var failure = Model.managementFailure(exitCode, managementErrors.text.trim() || managementOutput.text.trim()) + root.managementMessage = failure.message + root.managementAwaitingRefresh = failure.uncertain } else { root.managementMessage = root.managementAction === "copy" ? "Copy created. Refreshing machines…" : root.managementAction === "pause" ? "VM paused." diff --git a/tests/management-safety.test.cjs b/tests/management-safety.test.cjs new file mode 100644 index 0000000..9be0730 --- /dev/null +++ b/tests/management-safety.test.cjs @@ -0,0 +1,87 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const test = require('node:test'); +const source = name => fs.readFileSync(path.join(__dirname, '..', name), 'utf8'); +const model = {}; +vm.runInNewContext(source('Model.js').replace(/^\.pragma library\s*/, ''), model); + +// Exercise the actual JavaScript handlers without loading a desktop or spawning +// any SSH processes. This is not a substitute for manual QML focus testing. +function handler(text, pattern, context) { + const match = text.match(pattern); + assert.ok(match, 'expected QML handler must exist'); + return vm.runInNewContext('(function(event) {' + match[1] + '\n})', context); +} + +test('management transport failures retain unknown-outcome and copy retry guidance', () => { + for (const code of [124, 137, 143, 255, -1]) { + const failure = model.managementFailure(code, 'connection detail'); + assert.equal(failure.uncertain, true); + assert.match(failure.message, /may have completed remotely/); + assert.match(failure.message, /inventory before retrying/); + assert.match(failure.message, /another copy/); + assert.match(failure.message, /connection detail/); + } + const rejected = model.managementFailure(1, 'Permission denied'); + assert.equal(rejected.uncertain, false); + assert.equal(rejected.message, 'Permission denied'); + assert.ok(model.managementFailure(255, 'x'.repeat(1000)).message.length <= 500); +}); + +test('QML confirmation ignores autorepeat and accepts a fresh deliberate keypress', () => { + let confirmed = 0, cancelled = 0; + const Qt = {Key_Escape: 1, Key_Return: 2, Key_Enter: 3}; + const press = handler(source('VmDetail.qml'), /Keys\.onPressed: function\(event\) \{([\s\S]*?)\n \}\n Text \{/, { + Qt, root: {confirmPending() { confirmed++; }, cancelPending() { cancelled++; }}, + }); + for (const key of [Qt.Key_Return, Qt.Key_Enter]) { + const held = {key, isAutoRepeat: true, accepted: false}; + press(held); + assert.equal(held.accepted, true); + assert.equal(confirmed, key === Qt.Key_Return ? 0 : 1); + press({key, isAutoRepeat: false}); + } + assert.equal(confirmed, 2); + press({key: Qt.Key_Escape, isAutoRepeat: false}); + assert.equal(cancelled, 1); + assert.match(source('Widget.qml'), /blocked: filterField\.activeFocus \|\| detailView\.confirming/); +}); + +test('an in-flight stale inventory cannot unlock an uncertain management outcome', () => { + const text = source('VmService.qml'); + const queued = []; + const root = { + managementAwaitingRefresh: true, listReconcilesManagement: false, + refreshPending: false, lastError: '', + readList(code) { this.lastError = code === 0 ? '' : 'failed'; }, + }; + const listProcess = {running: true}; + const context = {root, listProcess, Qt: {callLater(fn) { queued.push(fn); }}}; + // QML exposes root properties unqualified inside its own functions. + for (const key of ['refreshPending', 'listReconcilesManagement', 'managementAwaitingRefresh']) { + Object.defineProperty(context, key, {get() { return root[key]; }, set(value) { root[key] = value; }}); + } + root.refresh = handler(text, /function refresh\(\) \{([\s\S]*?)\n \}/, context); + const exitedMatch = text.match(/id: listProcess[\s\S]*?onExited: function\(exitCode\) \{([\s\S]*?)\n \}/); + assert.ok(exitedMatch); + const exited = vm.runInNewContext('(function(exitCode) {' + exitedMatch[1] + '\n})', context); + root.refresh(); + assert.equal(root.refreshPending, true); + assert.equal(root.listReconcilesManagement, false); + listProcess.running = false; + exited(0); // Earlier request succeeds, but it predates the uncertain command. + assert.equal(root.managementAwaitingRefresh, true); + queued.shift()(); // The coalesced request starts after the command. + assert.equal(root.listReconcilesManagement, true); + listProcess.running = false; + exited(1); // A failed reconciliation must not unlock management. + assert.equal(root.managementAwaitingRefresh, true); + root.refresh(); + listProcess.running = false; + exited(0); + assert.equal(root.managementAwaitingRefresh, false); + assert.match(text, /readonly property bool managementBusy: managementProcess\.running \|\| managementAwaitingRefresh/); + assert.match(text, /function manage\(vmName, action\) \{\s*if \(managementBusy\) return/); +}); From a4c0b364793f57dfee34298be4709c719a9a1357 Mon Sep 17 00:00:00 2001 From: Christian Kreiling Date: Fri, 23 Oct 2026 19:18:04 -0700 Subject: [PATCH 3/3] Treat crashed SSH processes as uncertain remote operations --- Model.js | 7 ++-- VmService.qml | 16 ++++----- tests/management-safety.test.cjs | 59 ++++++++++++++++++++++++++++---- 3 files changed, 66 insertions(+), 16 deletions(-) diff --git a/Model.js b/Model.js index 365f490..5eabc3c 100644 --- a/Model.js +++ b/Model.js @@ -192,8 +192,11 @@ function sshCommand(vm) { // Fixed command/host and validated arguments only: SSH joins remote arguments // into a command string, so never pass arbitrary metadata or an arbitrary verb. -function managementFailure(exitCode, detail) { - var uncertain = exitCode === 124 || exitCode >= 128 || exitCode < 0; +function managementFailure(exitCode, detail, exitStatus) { + // QProcess::NormalExit is 0; CrashExit carries the raw signal number as + // exitCode, not necessarily the shell-style 128 + signal encoding. + var crashed = exitStatus !== undefined && exitStatus !== 0; + var uncertain = crashed || exitCode === 124 || exitCode >= 128 || exitCode < 0; var message = ""; if (uncertain) { message = (exitCode === 124 || exitCode === 137 ? "Request timed out" : "Connection lost or command interrupted") diff --git a/VmService.qml b/VmService.qml index 3b16d94..bc41388 100644 --- a/VmService.qml +++ b/VmService.qml @@ -48,8 +48,8 @@ Item { } } - function readList(exitCode) { - if (exitCode !== 0) { + function readList(exitCode, exitStatus) { + if (exitCode !== 0 || exitStatus !== 0) { lastError = exitCode === 124 || exitCode === 137 ? "exe.dev timed out. Check your connection and retry." : (errors.text.trim() || output.text.trim() || "Could not list VMs (exit " + exitCode + ").") @@ -83,8 +83,8 @@ Item { "exe.dev", "ls", "--json"] stdout: StdioCollector { id: output; waitForEnd: true } stderr: StdioCollector { id: errors; waitForEnd: true } - onExited: function(exitCode) { - root.readList(exitCode) + onExited: function(exitCode, exitStatus) { + root.readList(exitCode, exitStatus) if (root.listReconcilesManagement && root.lastError === "") root.managementAwaitingRefresh = false if (root.refreshPending) Qt.callLater(root.refresh) } @@ -94,10 +94,10 @@ Item { id: managementProcess stdout: StdioCollector { id: managementOutput; waitForEnd: true } stderr: StdioCollector { id: managementErrors; waitForEnd: true } - onExited: function(exitCode) { - root.managementError = exitCode !== 0 - if (exitCode !== 0) { - var failure = Model.managementFailure(exitCode, managementErrors.text.trim() || managementOutput.text.trim()) + onExited: function(exitCode, exitStatus) { + root.managementError = exitCode !== 0 || exitStatus !== 0 + if (root.managementError) { + var failure = Model.managementFailure(exitCode, managementErrors.text.trim() || managementOutput.text.trim(), exitStatus) root.managementMessage = failure.message root.managementAwaitingRefresh = failure.uncertain } else { diff --git a/tests/management-safety.test.cjs b/tests/management-safety.test.cjs index 9be0730..27528b8 100644 --- a/tests/management-safety.test.cjs +++ b/tests/management-safety.test.cjs @@ -28,6 +28,9 @@ test('management transport failures retain unknown-outcome and copy retry guidan assert.equal(rejected.uncertain, false); assert.equal(rejected.message, 'Permission denied'); assert.ok(model.managementFailure(255, 'x'.repeat(1000)).message.length <= 500); + for (const signal of [0, 9, 15]) { + assert.equal(model.managementFailure(signal, '', 1).uncertain, true); + } }); test('QML confirmation ignores autorepeat and accepts a fresh deliberate keypress', () => { @@ -55,7 +58,7 @@ test('an in-flight stale inventory cannot unlock an uncertain management outcome const root = { managementAwaitingRefresh: true, listReconcilesManagement: false, refreshPending: false, lastError: '', - readList(code) { this.lastError = code === 0 ? '' : 'failed'; }, + readList(code, status) { this.lastError = code === 0 && status === 0 ? '' : 'failed'; }, }; const listProcess = {running: true}; const context = {root, listProcess, Qt: {callLater(fn) { queued.push(fn); }}}; @@ -64,24 +67,68 @@ test('an in-flight stale inventory cannot unlock an uncertain management outcome Object.defineProperty(context, key, {get() { return root[key]; }, set(value) { root[key] = value; }}); } root.refresh = handler(text, /function refresh\(\) \{([\s\S]*?)\n \}/, context); - const exitedMatch = text.match(/id: listProcess[\s\S]*?onExited: function\(exitCode\) \{([\s\S]*?)\n \}/); + const exitedMatch = text.match(/id: listProcess[\s\S]*?onExited: function\(exitCode, exitStatus\) \{([\s\S]*?)\n \}/); assert.ok(exitedMatch); - const exited = vm.runInNewContext('(function(exitCode) {' + exitedMatch[1] + '\n})', context); + const exited = vm.runInNewContext('(function(exitCode, exitStatus) {' + exitedMatch[1] + '\n})', context); root.refresh(); assert.equal(root.refreshPending, true); assert.equal(root.listReconcilesManagement, false); listProcess.running = false; - exited(0); // Earlier request succeeds, but it predates the uncertain command. + exited(0, 0); // Earlier request succeeds, but it predates the uncertain command. assert.equal(root.managementAwaitingRefresh, true); queued.shift()(); // The coalesced request starts after the command. assert.equal(root.listReconcilesManagement, true); listProcess.running = false; - exited(1); // A failed reconciliation must not unlock management. + exited(1, 0); // A failed reconciliation must not unlock management. + assert.equal(root.managementAwaitingRefresh, true); + root.refresh(); + listProcess.running = false; + exited(0, 1); // Even exit code zero cannot reconcile a crashed query. assert.equal(root.managementAwaitingRefresh, true); root.refresh(); listProcess.running = false; - exited(0); + exited(0, 0); assert.equal(root.managementAwaitingRefresh, false); assert.match(text, /readonly property bool managementBusy: managementProcess\.running \|\| managementAwaitingRefresh/); assert.match(text, /function manage\(vmName, action\) \{\s*if \(managementBusy\) return/); }); + +test('actual management exit handler treats raw Qt CrashExit signals as uncertain', () => { + const match = source('VmService.qml').match(/id: managementProcess[\s\S]*?onExited: function\(exitCode, exitStatus\) \{([\s\S]*?)\n \}/); + assert.ok(match); + const queued = []; + const root = {managementAction: 'copy', refresh() {}}; + const exited = vm.runInNewContext('(function(exitCode, exitStatus) {' + match[1] + '\n})', { + root, Model: model, + managementErrors: {text: ''}, managementOutput: {text: ''}, + Qt: {callLater(fn) { queued.push(fn); }}, + }); + for (const code of [0, 9, 15]) { + root.managementAwaitingRefresh = false; + exited(code, 1); // QProcess::CrashExit, including raw SIGKILL (9). + assert.equal(root.managementError, true); + assert.equal(root.managementAwaitingRefresh, true); + assert.match(root.managementMessage, /may have completed remotely/); + assert.match(root.managementMessage, /another copy/); + } + assert.equal(queued.length, 3); + root.managementAwaitingRefresh = false; + exited(0, 0); // A normal successful copy must not be reported as a crash. + assert.equal(root.managementError, false); + assert.equal(root.managementAwaitingRefresh, false); + assert.match(root.managementMessage, /Copy created/); +}); + +test('actual inventory reader rejects CrashExit even with code zero and valid JSON', () => { + const match = source('VmService.qml').match(/function readList\(exitCode, exitStatus\) \{([\s\S]*?)\n \}/); + assert.ok(match); + const previous = [{name: 'previous'}]; + const context = { + vms: previous, lastError: '', errors: {text: ''}, output: {text: '{"vms":[]}'}, + Model: model, Qt: {formatTime() { return '12:00:00'; }}, + }; + const read = vm.runInNewContext('(function(exitCode, exitStatus) {' + match[1] + '\n})', context); + read(0, 1); + assert.notEqual(context.lastError, ''); + assert.equal(context.vms, previous); +});