Merge main into feat/bulk-rating and rebuild dist

This commit is contained in:
Ewald
2026-11-06 21:42:54 +01:00
32 changed files with 10491 additions and 7390 deletions
+1
View File
@@ -6,6 +6,7 @@ All notable changes to this project are documented in this file.
### New Features
- **Bulk star rating across a selection**: new batch route `POST /mjr/am/assets/rating` (`{ids, rating}`, max 500 ids, 10 req/min, per-id partial results) and the 0-5 hotkeys plus the grid "Set rating" submenu now rate multi-card selections in one batched, chunked request instead of one request per card (progress toast on large selections, `N rated, M failed` summary on partial failures, single-card behaviour unchanged).
- **Empty trash**: an "Empty trash" button in the Status panel permanently removes the files Majoor sent to the freedesktop trash (Linux/BSD). It only selects entries whose original location was inside a Majoor folder, shows their count and size before asking for confirmation, and shares the `allow_delete` / `MAJOOR_ALLOW_DELETE` gate with asset deletion. Directories in the trash are never removed (reported as skipped); trash directories owned by another user or group/world-writable are ignored. New routes `GET /mjr/am/trash` and `POST /mjr/am/trash/empty`.
- **Majoor Save Image formats**: `format` now offers `png 16-bit`, `exr` and `avif` (ComfyUI Save Image (Advanced) encoders) with `input_color_space` and AVIF `crf` options. The node also returns its `images` like the core Save Image node.
- **Majoor Save Video formats**: MP4, MKV and WebM containers with H.264 or AV1 (`mp4 (av1)`, `mkv (h264)`, `mkv (av1)`, `webm (av1)`). 10-bit and HDR (HLG / PQ) videos keep their bit depth and colour space. The node returns the encoded `images`.
- **EXR / TIFF / SVG / BMP / AVI / M4V indexing**: these formats are now indexed. EXR and TIFF are served to the viewer as a JPEG preview (EXR tone-mapped from scene-linear to sRGB); add `?original=1` to `/mjr/am/viewer/asset/{id}` for the source file. SVG is served sandboxed.
@@ -1,14 +1,15 @@
import { An as e, Cn as t, Dn as n, Gt as r, Mn as i, Nn as a, Sn as o, Tn as s, Xn as c, an as l, fn as u, hn as d, ln as f, mn as p, s as m, t as h, u as g, yn as _ } from "./config-D98p5fVn.js";
import { a as v, i as y, o as b, s as x } from "./graphTraversal-DTdQWkn5.js";
import { c as S, l as C, m as w, p as ee, s as T, t as E, u as D, v as O } from "./generationSectionState-BPjCPC13.js";
import { En as e, Mn as t, Sn as n, Wt as r, Yn as i, cn as a, dn as o, in as s, jn as c, kn as l, l as u, mn as d, o as f, pn as p, vn as m, wn as h, xn as g } from "./client-BVwNDRIx.js";
import { a as _, i as v, o as y, s as b } from "./graphTraversal-DTdQWkn5.js";
import { t as x } from "./config-BFDzOF1N.js";
import { c as S, l as C, m as w, p as ee, s as T, t as E, u as D, v as O } from "./generationSectionState-DwIh8vyW.js";
import { r as k } from "./events-1iwo5blr.js";
import { C as A, D as j, E as M, T as N, _ as P, a as F, b as I, d as L, f as R, g as z, h as B, m as te, n as V, o as ne, p as H, r as re, s as ie, t as U, v as ae, w as oe, y as se } from "./tooltipShortcuts-jtVskF3l.js";
import { I as ce } from "./mjr-primevue-C07IsEdU.js";
import { C as le, S as ue, n as de, t as fe, w as pe } from "./genInfo-BlwZQHxb.js";
import { C as A, D as j, E as M, T as N, _ as P, a as F, b as I, d as L, f as R, g as z, h as B, m as te, n as V, o as ne, p as H, r as re, s as ie, t as U, v as ae, w as oe, y as se } from "./tooltipShortcuts-CdiE8JHR.js";
import { I as ce } from "./mjr-primevue-CE8VjDUg.js";
import { C as le, S as ue, n as de, t as fe, w as pe } from "./genInfo-BQayEE-N.js";
// ui/features/viewer/videoSync.ts
var me = () => {
try {
return !!h?.DEBUG_VIEWER;
return !!x?.DEBUG_VIEWER;
} catch {
return !1;
}
@@ -592,7 +593,7 @@ function G(e) {
return a === ".gif" ? "gif" : Me.has(a) ? "video" : Ne.has(a) ? "audio" : ue.has(a) ? "model3d" : "image";
}
function Fe(e) {
return e ? e.url ? String(e.url) : e.filename && c(e) || "" : "";
return e ? e.url ? String(e.url) : e.filename && i(e) || "" : "";
}
function K(e = "No media - select assets in the grid") {
let t = document.createElement("div");
@@ -743,7 +744,7 @@ function J(e, { fill: t = !1, controls: n = !0, initialMuted: r = !1, initialPla
canvasClassName: `mjr-mfv-media mjr-model3d-render-canvas${t ? " mjr-mfv-media--fill" : ""}`,
hintText: "Rotate: left drag Pan: right drag Zoom: wheel or middle drag",
disableViewerTransform: !0,
pauseDuringExecution: !!h.FLOATING_VIEWER_PAUSE_DURING_EXECUTION
pauseDuringExecution: !!x.FLOATING_VIEWER_PAUSE_DURING_EXECUTION
});
let l = document.createElement("img");
return l.className = s, l.src = a, l.alt = String(e?.filename || ""), l.draggable = !1, (o === "gif" || c) && Ae(l, e, { kind: o === "gif" ? "gif" : "animated-image" }) || l;
@@ -856,80 +857,80 @@ var nt = class {
}), this._onToggle?.(this._expanded);
}
_render() {
let t = this._node, n = document.createElement("section");
n.className = "mjr-ws-node", n.dataset.nodeId = String(t.id ?? ""), this._isSubgraph && (n.classList.add("mjr-ws-node--subgraph"), n.dataset.subgraph = "true", n.dataset.childCount = String(this._childCount)), this._depth > 0 && (n.dataset.depth = String(this._depth), n.classList.add("mjr-ws-node--nested"));
let r = document.createElement("div");
if (r.className = "mjr-ws-node-header", this._collapsible) {
this._header = r;
let e = this._node, t = document.createElement("section");
t.className = "mjr-ws-node", t.dataset.nodeId = String(e.id ?? ""), this._isSubgraph && (t.classList.add("mjr-ws-node--subgraph"), t.dataset.subgraph = "true", t.dataset.childCount = String(this._childCount)), this._depth > 0 && (t.dataset.depth = String(this._depth), t.classList.add("mjr-ws-node--nested"));
let n = document.createElement("div");
if (n.className = "mjr-ws-node-header", this._collapsible) {
this._header = n;
let e = document.createElement("button");
e.type = "button", e.className = "mjr-icon-btn mjr-ws-node-toggle", e.title = this._expanded ? "Collapse node" : "Expand node", e.addEventListener("click", (e) => {
e.stopPropagation(), this.setExpanded(!this._expanded);
}), r.appendChild(e), this._toggleBtn = e, r.addEventListener("click", (e) => {
}), n.appendChild(e), this._toggleBtn = e, n.addEventListener("click", (e) => {
e.target?.closest?.("button") || this.setExpanded(!this._expanded);
}), r.title = this._expanded ? "Collapse node" : "Expand node";
}), n.title = this._expanded ? "Collapse node" : "Expand node";
}
let i = document.createElement("div");
i.className = "mjr-ws-node-title-wrap";
let a = document.createElement("span");
a.className = "mjr-ws-node-type";
let o = et(t, { isSubgraph: this._isSubgraph });
a.textContent = o, i.appendChild(a);
let s = tt(t, o, { isSubgraph: this._isSubgraph });
if (s) {
let r = document.createElement("div");
r.className = "mjr-ws-node-title-wrap";
let i = document.createElement("span");
i.className = "mjr-ws-node-type";
let a = et(e, { isSubgraph: this._isSubgraph });
i.textContent = a, r.appendChild(i);
let o = tt(e, a, { isSubgraph: this._isSubgraph });
if (o) {
let e = document.createElement("span");
e.className = "mjr-ws-node-title", e.textContent = s, i.appendChild(e);
e.className = "mjr-ws-node-title", e.textContent = o, r.appendChild(e);
}
if (r.appendChild(i), this._isSubgraph) {
if (n.appendChild(r), this._isSubgraph) {
let e = document.createElement("span");
e.className = "mjr-ws-node-kind", e.title = `${this._childCount} inner node${this._childCount === 1 ? "" : "s"}`;
let t = document.createElement("i");
t.className = "pi pi-sitemap", t.setAttribute("aria-hidden", "true"), e.appendChild(t);
let n = document.createElement("span");
n.textContent = "Subgraph", e.appendChild(n);
let r = document.createElement("span");
r.textContent = "Subgraph", e.appendChild(r);
let i = document.createElement("span");
i.className = "mjr-ws-node-kind-count", i.textContent = String(this._childCount), e.appendChild(i), r.appendChild(e), this._subgraphHeaderTitle = `${o} | Subgraph | ${this._childCount} inner node${this._childCount === 1 ? "" : "s"}`, r.title = this._subgraphHeaderTitle;
i.className = "mjr-ws-node-kind-count", i.textContent = String(this._childCount), e.appendChild(i), n.appendChild(e), this._subgraphHeaderTitle = `${a} | Subgraph | ${this._childCount} inner node${this._childCount === 1 ? "" : "s"}`, n.title = this._subgraphHeaderTitle;
}
let c = document.createElement("button");
c.type = "button", c.className = "mjr-icon-btn mjr-ws-locate", c.title = "Locate on canvas", c.innerHTML = "<i class=\"pi pi-map-marker\" aria-hidden=\"true\"></i>", c.addEventListener("click", (e) => {
let s = document.createElement("button");
s.type = "button", s.className = "mjr-icon-btn mjr-ws-locate", s.title = "Locate on canvas", s.innerHTML = "<i class=\"pi pi-map-marker\" aria-hidden=\"true\"></i>", s.addEventListener("click", (e) => {
e.stopPropagation(), this._locateNode();
}), r.appendChild(c), n.appendChild(r);
let l = document.createElement("div");
if (l.className = "mjr-ws-node-body", Ke(t)) {
let r = document.createElement("textarea");
r.className = "mjr-ws-input mjr-ws-textarea mjr-ws-note-textarea", r.value = qe(t), r.rows = 4;
let i = () => {
r.style.height = "auto", r.style.height = r.scrollHeight + "px";
}), n.appendChild(s), t.appendChild(n);
let c = document.createElement("div");
if (c.className = "mjr-ws-node-body", Ke(e)) {
let n = document.createElement("textarea");
n.className = "mjr-ws-input mjr-ws-textarea mjr-ws-note-textarea", n.value = qe(e), n.rows = 4;
let r = () => {
n.style.height = "auto", n.style.height = n.scrollHeight + "px";
};
return r.addEventListener("input", () => {
Je(t, r.value);
let n = t?.widgets?.[0], a = n?.inputEl ?? n?.element ?? n?.el ?? null;
a != null && "value" in a && a.value !== r.value && (a.value = r.value), i();
return n.addEventListener("input", () => {
Je(e, n.value);
let t = e?.widgets?.[0], i = t?.inputEl ?? t?.element ?? t?.el ?? null;
i != null && "value" in i && i.value !== n.value && (i.value = n.value), r();
try {
e(t);
l(e);
} catch {}
}), r._mjrAutoFit = i, this._noteTextarea = r, this._autoFits.push(i), l.appendChild(r), this._body = l, n.appendChild(l), this._el = n, this._applyExpandedState(), requestAnimationFrame(i), n;
}), n._mjrAutoFit = r, this._noteTextarea = n, this._autoFits.push(r), c.appendChild(n), this._body = c, t.appendChild(c), this._el = t, this._applyExpandedState(), requestAnimationFrame(r), t;
}
let u = t.widgets ?? [], d = !1;
for (let e of u) {
let n = String(e.type || "").toLowerCase();
if (We.has(n) || e.hidden || e.options?.hidden) continue;
let u = e.widgets ?? [], d = !1;
for (let t of u) {
let n = String(t.type || "").toLowerCase();
if (We.has(n) || t.hidden || t.options?.hidden) continue;
d = !0;
let r = n === "text" || n === "string" || n === "customtext", i = document.createElement("div");
i.className = r ? "mjr-ws-widget-row mjr-ws-widget-row--block" : "mjr-ws-widget-row";
let a = document.createElement("label");
a.className = "mjr-ws-widget-label", a.textContent = e.name || "";
a.className = "mjr-ws-widget-label", a.textContent = t.name || "";
let o = document.createElement("div");
o.className = "mjr-ws-widget-input";
let s = F(e, () => {}, t);
o.appendChild(s), this._inputMap.set(e.name, s);
let c = s._mjrAutoFit ?? s.querySelector?.("textarea")?._mjrAutoFit;
c && this._autoFits.push(c), i.appendChild(a), i.appendChild(o), l.appendChild(i);
let s = F(t, () => {}, e);
o.appendChild(s), this._inputMap.set(t.name, s);
let l = s._mjrAutoFit ?? s.querySelector?.("textarea")?._mjrAutoFit;
l && this._autoFits.push(l), i.appendChild(a), i.appendChild(o), c.appendChild(i);
}
if (!d) {
let e = document.createElement("div");
e.className = "mjr-ws-node-empty", e.textContent = "No editable parameters", l.appendChild(e);
e.className = "mjr-ws-node-empty", e.textContent = "No editable parameters", c.appendChild(e);
}
return this._body = l, n.appendChild(l), this._el = n, this._applyExpandedState(), n;
return this._body = c, t.appendChild(c), this._el = t, this._applyExpandedState(), t;
}
_applyExpandedState() {
if (this._el && this._body) {
@@ -1085,18 +1086,18 @@ var it = class {
};
function at() {
try {
return _();
return m();
} catch {
return null;
}
}
function ot(e) {
return b(e);
return y(e);
}
function st(e, t = /* @__PURE__ */ new Set()) {
if (!e || t.has(e)) return [];
t.add(e);
let n = y(e), r = [];
let n = v(e), r = [];
for (let e of n) {
if (!e) continue;
let n = ot(e).flatMap((e) => st(e, t));
@@ -1142,7 +1143,7 @@ function dt(e) {
}
function ft() {
try {
return t();
return n();
} catch (e) {
console.debug?.("[MFV] _getSelectedNodeIds", e);
}
@@ -1168,62 +1169,62 @@ function ht(e) {
}
}
function gt(e) {
return s(e);
return h(e);
}
function _t(e) {
let t = _();
let t = m();
if (!e || !t || typeof t.add != "function") return !1;
let n = String(e?.type || e?.class_type || e?.comfyClass || "").trim(), r = null;
try {
r = u(n);
r = o(n);
} catch (e) {
console.debug?.("[MFV Graph Map] createNode failed", e);
}
if (!r) return !1;
try {
let t = typeof structuredClone == "function" ? structuredClone(e) : JSON.parse(JSON.stringify(e));
return St(t, e), delete t.id, Array.isArray(t.pos) && (t.pos = [Number(t.pos[0] || 0) + 32, Number(t.pos[1] || 0) + 32]), typeof r.configure == "function" ? r.configure(t) : Object.assign(r, t), f(r);
return St(t, e), delete t.id, Array.isArray(t.pos) && (t.pos = [Number(t.pos[0] || 0) + 32, Number(t.pos[1] || 0) + 32]), typeof r.configure == "function" ? r.configure(t) : Object.assign(r, t), a(r);
} catch (e) {
return console.debug?.("[MFV Graph Map] import node failed", e), !1;
}
}
function vt(e) {
let t = yt();
if (!e || !t) return {
let n = yt();
if (!e || !n) return {
ok: !1,
count: 0,
reason: "no-target"
};
let n = bt(e), r = Array.isArray(t.widgets) ? t.widgets : [];
if (!n.length || !r.length) return {
let r = bt(e), i = Array.isArray(n.widgets) ? n.widgets : [];
if (!r.length || !i.length) return {
ok: !1,
count: 0,
reason: "no-widgets"
};
let i = /* @__PURE__ */ new Map();
r.forEach((e, t) => {
for (let n of wt(e)) i.has(n) || i.set(n, {
let a = /* @__PURE__ */ new Map();
i.forEach((e, t) => {
for (let n of wt(e)) a.has(n) || a.set(n, {
widget: e,
index: t
});
});
let o = Et(B(e)), s = Et(t?.type || t?.comfyClass || t?.class_type), c = !!(o && s && o === s), l = /* @__PURE__ */ new Set(), u = 0;
for (let e of n) {
let n = Tt(e.label), a = n ? i.get(n) : null, o = e.index;
if ((!a || l.has(a.index)) && c && typeof o == "number" && Number.isInteger(o)) {
let e = r[o];
e && (a = {
let o = Et(B(e)), s = Et(n?.type || n?.comfyClass || n?.class_type), c = !!(o && s && o === s), l = /* @__PURE__ */ new Set(), u = 0;
for (let e of r) {
let t = Tt(e.label), r = t ? a.get(t) : null, o = e.index;
if ((!r || l.has(r.index)) && c && typeof o == "number" && Number.isInteger(o)) {
let e = i[o];
e && (r = {
widget: e,
index: o
});
}
a && !l.has(a.index) && ne(a.widget, X(e.value), t) && (l.add(a.index), u += 1);
r && !l.has(r.index) && ne(r.widget, X(e.value), n) && (l.add(r.index), u += 1);
}
return a(), {
return t(), {
ok: u > 0,
count: u,
reason: u > 0 ? "ok" : "no-match",
targetNode: t
targetNode: n
};
}
function yt() {
@@ -1735,14 +1736,14 @@ var Ht = 16, Ut = 250, Wt = class {
let t = document.createElement("div");
t.className = "mjr-ws-sidebar-header";
let n = document.createElement("span");
n.className = "mjr-ws-sidebar-title", n.textContent = l("workflowSidebar.nodes", "Nodes"), t.appendChild(n);
n.className = "mjr-ws-sidebar-title", n.textContent = s("workflowSidebar.nodes", "Nodes"), t.appendChild(n);
let r = document.createElement("button");
r.type = "button", r.className = "mjr-icon-btn", r.title = l("workflowSidebar.close", "Close sidebar"), r.innerHTML = "<i class=\"pi pi-times\" aria-hidden=\"true\"></i>", r.addEventListener("click", () => {
r.type = "button", r.className = "mjr-icon-btn", r.title = s("workflowSidebar.close", "Close sidebar"), r.innerHTML = "<i class=\"pi pi-times\" aria-hidden=\"true\"></i>", r.addEventListener("click", () => {
let e = this._activeMode === "graph";
this.hide(), e && this._onCloseGraphMap?.(), this._onClose?.();
}), t.appendChild(r), e.appendChild(t);
let i = document.createElement("div");
i.className = "mjr-ws-tab-bar", this._nodesModeBtn = this._makeModeButton(l("workflowSidebar.nodes", "Nodes"), "pi pi-sliders-h", "nodes"), this._graphModeBtn = this._makeModeButton(l("workflowSidebar.graphMap", "Graph Map"), "pi pi-sitemap", "graph"), i.appendChild(this._nodesModeBtn), i.appendChild(this._graphModeBtn), e.appendChild(i);
i.className = "mjr-ws-tab-bar", this._nodesModeBtn = this._makeModeButton(s("workflowSidebar.nodes", "Nodes"), "pi pi-sliders-h", "nodes"), this._graphModeBtn = this._makeModeButton(s("workflowSidebar.graphMap", "Graph Map"), "pi pi-sitemap", "graph"), i.appendChild(this._nodesModeBtn), i.appendChild(this._graphModeBtn), e.appendChild(i);
let a = document.createElement("div");
return a.className = "mjr-ws-sidebar-resizer", a.setAttribute("role", "separator"), a.setAttribute("aria-orientation", "vertical"), a.setAttribute("aria-hidden", "true"), e.appendChild(a), this._bindResize(a), this._body = document.createElement("div"), this._body.className = "mjr-ws-sidebar-body", e.appendChild(this._body), this._renderActiveMode(), e;
}
@@ -1834,90 +1835,90 @@ var Q = Object.freeze({
"latent2rgb",
"taesd",
"none"
]), Jt = "progress-update", Yt = (e, t = "") => l(e, t, void 0);
]), Jt = "progress-update", Yt = (e, t = "") => s(e, t, void 0);
function Xt() {
let e = document.createElement("div");
e.className = "mjr-mfv-run-controls";
let t = document.createElement("button");
t.type = "button", t.className = "mjr-icon-btn mjr-mfv-run-btn";
let t = document.createElement("div");
t.className = "mjr-mfv-run-controls";
let n = document.createElement("button");
n.type = "button", n.className = "mjr-icon-btn mjr-mfv-run-btn";
let r = Yt("tooltip.queuePrompt", "Queue Prompt (Run)");
t.title = r, t.setAttribute("aria-label", r);
n.title = r, n.setAttribute("aria-label", r);
let i = document.createElement("i");
i.className = "pi pi-play", i.setAttribute("aria-hidden", "true"), t.appendChild(i);
i.className = "pi pi-play", i.setAttribute("aria-hidden", "true"), n.appendChild(i);
let a = document.createElement("button");
a.type = "button", a.className = "mjr-icon-btn mjr-mfv-stop-btn";
let s = document.createElement("i");
s.className = "pi pi-stop", s.setAttribute("aria-hidden", "true"), a.appendChild(s), e.appendChild(t), e.appendChild(a);
let c = Q.IDLE, l = !1, u = !1, d = null;
function f() {
d != null && (clearTimeout(d), d = null);
let o = document.createElement("i");
o.className = "pi pi-stop", o.setAttribute("aria-hidden", "true"), a.appendChild(o), t.appendChild(n), t.appendChild(a);
let s = Q.IDLE, c = !1, l = !1, u = null;
function d() {
u != null && (clearTimeout(u), u = null);
}
function p(e, { canStop: n = !1 } = {}) {
c = e, t.classList.toggle("running", c === Q.RUNNING), t.classList.toggle("stopping", c === Q.STOPPING), t.classList.toggle("error", c === Q.ERROR), t.disabled = c === Q.RUNNING || c === Q.STOPPING, a.disabled = !n || c === Q.STOPPING, a.classList.toggle("active", n && c !== Q.STOPPING), a.classList.toggle("stopping", c === Q.STOPPING), c === Q.RUNNING || c === Q.STOPPING ? i.className = "pi pi-spin pi-spinner" : i.className = "pi pi-play";
function f(e, { canStop: t = !1 } = {}) {
s = e, n.classList.toggle("running", s === Q.RUNNING), n.classList.toggle("stopping", s === Q.STOPPING), n.classList.toggle("error", s === Q.ERROR), n.disabled = s === Q.RUNNING || s === Q.STOPPING, a.disabled = !t || s === Q.STOPPING, a.classList.toggle("active", t && s !== Q.STOPPING), a.classList.toggle("stopping", s === Q.STOPPING), s === Q.RUNNING || s === Q.STOPPING ? i.className = "pi pi-spin pi-spinner" : i.className = "pi pi-play";
}
function m() {
function p() {
let e = Yt("tooltip.queueStop", "Stop Generation");
a.title = e, a.setAttribute("aria-label", e);
}
function h(e = j.getSnapshot(), { authoritative: t = !1 } = {}) {
function m(e = j.getSnapshot(), { authoritative: t = !1 } = {}) {
let n = Math.max(0, Number(e?.queue) || 0), r = e?.prompt || null, i = !!r?.currentlyExecuting, a = !!(r && (r.currentlyExecuting || r.errorDetails)), o = n > 0 || a, s = !!r?.errorDetails;
t && n === 0 && !r && (l = !1, u = !1);
let c = l || u || i || n > 0;
if ((i || o || n > 0) && (l = !1), s) u = !1, f(), p(Q.ERROR, { canStop: !1 });
else if (u) {
if (!c) {
u = !1, h(e);
t && n === 0 && !r && (c = !1, l = !1);
let u = c || l || i || n > 0;
if ((i || o || n > 0) && (c = !1), s) l = !1, d(), f(Q.ERROR, { canStop: !1 });
else if (l) {
if (!u) {
l = !1, m(e);
return;
}
p(Q.STOPPING, { canStop: !1 });
} else l || i || o || n > 0 ? (f(), p(Q.RUNNING, { canStop: !0 })) : (f(), p(Q.IDLE, { canStop: !1 }));
f(Q.STOPPING, { canStop: !1 });
} else c || i || o || n > 0 ? (d(), f(Q.RUNNING, { canStop: !0 })) : (d(), f(Q.IDLE, { canStop: !1 }));
}
function g() {
l = !1, u = !1, f(), p(Q.ERROR, { canStop: !1 }), d = setTimeout(() => {
d = null, h();
function h() {
c = !1, l = !1, d(), f(Q.ERROR, { canStop: !1 }), u = setTimeout(() => {
u = null, m();
}, 1500);
}
async function _() {
return { tracked: await n(o()) };
return { tracked: await e(g()) };
}
async function v() {
if (c !== Q.RUNNING && c !== Q.STOPPING) {
l = !0, u = !1, h();
if (s !== Q.RUNNING && s !== Q.STOPPING) {
c = !0, l = !1, m();
try {
(await nn())?.tracked || (l = !1), h();
(await nn())?.tracked || (c = !1), m();
} catch (e) {
console.error?.("[MFV Run]", e), g();
console.error?.("[MFV Run]", e), h();
}
}
}
async function y() {
if (c === Q.RUNNING) {
u = !0, h();
if (s === Q.RUNNING) {
l = !0, m();
try {
(await _())?.tracked || (u = !1, l = !1), h();
(await _())?.tracked || (l = !1, c = !1), m();
} catch (e) {
console.error?.("[MFV Stop]", e), u = !1, h();
console.error?.("[MFV Stop]", e), l = !1, m();
}
}
}
m(), a.disabled = !0, t.addEventListener("click", v), a.addEventListener("click", y);
p(), a.disabled = !0, n.addEventListener("click", v), a.addEventListener("click", y);
let b = (e) => {
h(e?.detail || j.getSnapshot(), { authoritative: !0 });
m(e?.detail || j.getSnapshot(), { authoritative: !0 });
};
return j.addEventListener(Jt, b), M({ timeoutMs: 4e3 }).catch((e) => {
console.debug?.(e);
}), h(), {
el: e,
}), m(), {
el: t,
dispose() {
f(), t.removeEventListener("click", v), a.removeEventListener("click", y), j.removeEventListener(Jt, b);
d(), n.removeEventListener("click", v), a.removeEventListener("click", y), j.removeEventListener(Jt, b);
}
};
}
function Zt(e = h.MFV_PREVIEW_METHOD) {
function Zt(e = x.MFV_PREVIEW_METHOD) {
let t = String(e || "").trim().toLowerCase();
return qt.has(t) ? t : h.MFV_PREVIEW_METHOD || "auto";
return qt.has(t) ? t : x.MFV_PREVIEW_METHOD || "auto";
}
function Qt(e, t = h.MFV_PREVIEW_METHOD) {
function Qt(e, t = x.MFV_PREVIEW_METHOD) {
let n = Zt(t), r = {
...e?.extra_data || {},
extra_pnginfo: { ...e?.extra_data?.extra_pnginfo || {} }
@@ -1927,7 +1928,7 @@ function Qt(e, t = h.MFV_PREVIEW_METHOD) {
extra_data: r
};
}
function $t(e, { previewMethod: t = h.MFV_PREVIEW_METHOD, clientId: n = null } = {}) {
function $t(e, { previewMethod: t = x.MFV_PREVIEW_METHOD, clientId: n = null } = {}) {
let r = Qt(e, t), i = {
prompt: r?.output,
extra_data: r?.extra_data || {}
@@ -1945,15 +1946,15 @@ function en(e) {
}
function tn(e) {
let t = !1;
return x(e, ({ node: e }) => {
return b(e, ({ node: e }) => {
t ||= en(e);
}), t;
}
async function nn() {
let e = o();
let e = g();
if (!e) throw Error("ComfyUI app not available");
let t = tn(v(e));
return { tracked: await i({
let t = tn(_(e));
return { tracked: await c({
app: e,
forceNativeQueue: t,
resolvePromptData(e) {
@@ -2083,7 +2084,7 @@ function dn(e) {
let i = document.createElement("span");
i.textContent = "Majoor Floating Viewer", n.append(r, i);
let a = document.createElement("button");
e._closeBtn = a, a.type = "button", a.className = "mjr-icon-btn mjr-mfv-close-btn", V(a, l("tooltip.closeViewer", "Close viewer"), "Esc");
e._closeBtn = a, a.type = "button", a.className = "mjr-icon-btn mjr-mfv-close-btn", V(a, s("tooltip.closeViewer", "Close viewer"), "Esc");
let o = document.createElement("i");
return o.className = "pi pi-times", o.setAttribute("aria-hidden", "true"), a.appendChild(o), t.appendChild(n), t.appendChild(a), t;
}
@@ -2101,31 +2102,31 @@ function fn(e) {
};
}, n = document.createElement("div");
n.className = "mjr-mfv-toolbar";
let r = on("<i class=\"pi pi-image\" aria-hidden=\"true\"></i>", l("viewer.mode", "Viewer mode"), [
let r = on("<i class=\"pi pi-image\" aria-hidden=\"true\"></i>", s("viewer.mode", "Viewer mode"), [
{
value: W.SIMPLE,
html: `<i class="pi pi-image" aria-hidden="true"></i><span>${l("viewer.mode.simple", "Simple")}</span>`
html: `<i class="pi pi-image" aria-hidden="true"></i><span>${s("viewer.mode.simple", "Simple")}</span>`
},
{
value: W.AB,
html: `<i class="pi pi-clone" aria-hidden="true"></i><span>${l("viewer.mode.abCompare", "A/B Compare")}</span>`
html: `<i class="pi pi-clone" aria-hidden="true"></i><span>${s("viewer.mode.abCompare", "A/B Compare")}</span>`
},
{
value: W.SIDE,
html: `<i class="pi pi-table" aria-hidden="true"></i><span>${l("viewer.mode.sideBySide", "Side-by-side")}</span>`
html: `<i class="pi pi-table" aria-hidden="true"></i><span>${s("viewer.mode.sideBySide", "Side-by-side")}</span>`
},
{
value: W.GRID,
html: `<i class="pi pi-th-large" aria-hidden="true"></i><span>${l("viewer.mode.grid", "Grid")}</span>`
html: `<i class="pi pi-th-large" aria-hidden="true"></i><span>${s("viewer.mode.grid", "Grid")}</span>`
},
{
value: W.GRAPH,
html: `<i class="pi pi-sitemap" aria-hidden="true"></i><span>${l("workflowSidebar.graphMap", "Graph Map")}</span>`
html: `<i class="pi pi-sitemap" aria-hidden="true"></i><span>${s("workflowSidebar.graphMap", "Graph Map")}</span>`
}
], e);
e._modeDrop = r, e._modeBtn = r.trigger, e._modeSelect = r.select, e._updateModeBtnUI(), n.appendChild(r.wrap);
let i = document.createElement("button");
i.type = "button", i.className = "mjr-icon-btn mjr-mfv-pin-trigger", i.title = l("viewer.pinSlots", "Pin slots A/B/C/D"), i.setAttribute("aria-haspopup", "dialog"), i.setAttribute("aria-expanded", "false"), i.innerHTML = "<i class=\"pi pi-map-marker\" aria-hidden=\"true\"></i>", n.appendChild(i);
i.type = "button", i.className = "mjr-icon-btn mjr-mfv-pin-trigger", i.title = s("viewer.pinSlots", "Pin slots A/B/C/D"), i.setAttribute("aria-haspopup", "dialog"), i.setAttribute("aria-expanded", "false"), i.innerHTML = "<i class=\"pi pi-map-marker\" aria-hidden=\"true\"></i>", n.appendChild(i);
let a = document.createElement("div");
a.className = "mjr-mfv-pin-popover", e.element.appendChild(a);
let o = document.createElement("div");
@@ -2144,19 +2145,19 @@ function fn(e) {
i.className = "pi pi-map-marker mjr-menu-item-check", i.style.opacity = "0", n.appendChild(r), n.appendChild(i), o.appendChild(n), e._pinBtns[t] = n;
}
e._updatePinUI(), e._pinBtn = i, e._pinPopover = a;
let s = () => {
let c = () => {
let t = i.getBoundingClientRect(), n = e.element.getBoundingClientRect();
a.style.left = `${t.left - n.left}px`, a.style.top = `${t.bottom - n.top + 4}px`, a.classList.add("is-open"), i.setAttribute("aria-expanded", "true");
};
e._closePinPopover = () => {
a.classList.remove("is-open"), i.setAttribute("aria-expanded", "false");
}, i.addEventListener("click", (t) => {
t.stopPropagation(), a.classList.contains("is-open") ? e._closePinPopover() : (e._closeAllToolbarPopovers?.(), s());
t.stopPropagation(), a.classList.contains("is-open") ? e._closePinPopover() : (e._closeAllToolbarPopovers?.(), c());
});
let c = document.createElement("button");
c.type = "button", c.className = "mjr-icon-btn mjr-mfv-guides-trigger", c.title = "Guides", c.setAttribute("aria-haspopup", "listbox"), c.setAttribute("aria-expanded", "false");
let l = document.createElement("button");
l.type = "button", l.className = "mjr-icon-btn mjr-mfv-guides-trigger", l.title = "Guides", l.setAttribute("aria-haspopup", "listbox"), l.setAttribute("aria-expanded", "false");
let u = document.createElement("img");
u.src = new URL("data:image/png;base64,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", "" + import.meta.url).href, u.className = "mjr-mfv-guides-icon", u.alt = "", u.setAttribute("aria-hidden", "true"), c.appendChild(u), n.appendChild(c);
u.src = new URL("data:image/png;base64,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", "" + import.meta.url).href, u.className = "mjr-mfv-guides-icon", u.alt = "", u.setAttribute("aria-hidden", "true"), l.appendChild(u), n.appendChild(l);
let d = document.createElement("div");
d.className = "mjr-mfv-guides-popover", e.element.appendChild(d);
let f = document.createElement("div");
@@ -2191,14 +2192,14 @@ function fn(e) {
let i = document.createElement("i");
i.className = "pi pi-check mjr-menu-item-check", i.style.opacity = e.value === h ? "1" : "0", n.appendChild(r), n.appendChild(i), f.appendChild(n), g.push(n), e.value === h && n.classList.add("is-active");
}
p.value = h, c.classList.toggle("is-on", h !== "0"), e._guidesSelect = p, e._guideBtn = c, e._guidePopover = d;
p.value = h, l.classList.toggle("is-on", h !== "0"), e._guidesSelect = p, e._guideBtn = l, e._guidePopover = d;
let _ = () => {
let t = c.getBoundingClientRect(), n = e.element.getBoundingClientRect();
d.style.left = `${t.left - n.left}px`, d.style.top = `${t.bottom - n.top + 4}px`, d.classList.add("is-open"), c.setAttribute("aria-expanded", "true");
let t = l.getBoundingClientRect(), n = e.element.getBoundingClientRect();
d.style.left = `${t.left - n.left}px`, d.style.top = `${t.bottom - n.top + 4}px`, d.classList.add("is-open"), l.setAttribute("aria-expanded", "true");
};
e._closeGuidePopover = () => {
d.classList.remove("is-open"), c.setAttribute("aria-expanded", "false");
}, c.addEventListener("click", (t) => {
d.classList.remove("is-open"), l.setAttribute("aria-expanded", "false");
}, l.addEventListener("click", (t) => {
t.stopPropagation(), d.classList.contains("is-open") ? e._closeGuidePopover() : (e._closeAllToolbarPopovers?.(), _());
}), f.addEventListener("click", (t) => {
let n = t.target.closest(".mjr-menu-item");
@@ -2207,7 +2208,7 @@ function fn(e) {
p.value = r ?? "", p.dispatchEvent(new Event("change", { bubbles: !0 })), g.forEach((e) => {
let t = e.dataset.value === r;
e.classList.toggle("is-active", t), e.querySelector(".mjr-menu-item-check").style.opacity = t ? "1" : "0";
}), c.classList.toggle("is-on", r !== "0"), e._closeGuidePopover();
}), l.classList.toggle("is-on", r !== "0"), e._closeGuidePopover();
});
let v = String(e._channel || "rgb"), y = document.createElement("button");
y.type = "button", y.className = "mjr-icon-btn mjr-mfv-ch-trigger", y.title = "Channel", y.setAttribute("aria-haspopup", "listbox"), y.setAttribute("aria-expanded", "false");
@@ -2376,24 +2377,24 @@ function fn(e) {
}), e._closeFormatPopover();
});
let L = document.createElement("div");
L.className = "mjr-mfv-toolbar-sep", L.setAttribute("aria-hidden", "true"), n.appendChild(L), e._liveBtn = document.createElement("button"), e._liveBtn.type = "button", e._liveBtn.className = "mjr-icon-btn", e._liveBtn.innerHTML = "<i class=\"pi pi-circle\" aria-hidden=\"true\"></i>", e._liveBtn.setAttribute("aria-pressed", "false"), V(e._liveBtn, l("tooltip.liveStreamOff", "Live Stream: OFF - click to follow final generation outputs"), "L"), n.appendChild(e._liveBtn), e._previewBtn = document.createElement("button"), e._previewBtn.type = "button", e._previewBtn.className = "mjr-icon-btn", e._previewBtn.innerHTML = "<i class=\"pi pi-eye\" aria-hidden=\"true\"></i>", e._previewBtn.setAttribute("aria-pressed", "false"), V(e._previewBtn, l("tooltip.previewStreamOff", "KSampler Preview: OFF - click to stream sampler denoising frames"), "K"), n.appendChild(e._previewBtn), e._nodeStreamBtn = document.createElement("button"), e._nodeStreamBtn.type = "button", e._nodeStreamBtn.className = "mjr-icon-btn", e._nodeStreamBtn.innerHTML = "<i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>", e._nodeStreamBtn.setAttribute("aria-pressed", "false"), V(e._nodeStreamBtn, l("tooltip.nodeStreamOff", "Node Stream: OFF - click to follow selected node previews, including ImageOps live canvases"), "N"), n.appendChild(e._nodeStreamBtn), e._genBtn = document.createElement("button"), e._genBtn.type = "button", e._genBtn.className = "mjr-icon-btn", e._genBtn.setAttribute("aria-pressed", String(!!e._genSidebarEnabled));
L.className = "mjr-mfv-toolbar-sep", L.setAttribute("aria-hidden", "true"), n.appendChild(L), e._liveBtn = document.createElement("button"), e._liveBtn.type = "button", e._liveBtn.className = "mjr-icon-btn", e._liveBtn.innerHTML = "<i class=\"pi pi-circle\" aria-hidden=\"true\"></i>", e._liveBtn.setAttribute("aria-pressed", "false"), V(e._liveBtn, s("tooltip.liveStreamOff", "Live Stream: OFF - click to follow final generation outputs"), "L"), n.appendChild(e._liveBtn), e._previewBtn = document.createElement("button"), e._previewBtn.type = "button", e._previewBtn.className = "mjr-icon-btn", e._previewBtn.innerHTML = "<i class=\"pi pi-eye\" aria-hidden=\"true\"></i>", e._previewBtn.setAttribute("aria-pressed", "false"), V(e._previewBtn, s("tooltip.previewStreamOff", "KSampler Preview: OFF - click to stream sampler denoising frames"), "K"), n.appendChild(e._previewBtn), e._nodeStreamBtn = document.createElement("button"), e._nodeStreamBtn.type = "button", e._nodeStreamBtn.className = "mjr-icon-btn", e._nodeStreamBtn.innerHTML = "<i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>", e._nodeStreamBtn.setAttribute("aria-pressed", "false"), V(e._nodeStreamBtn, s("tooltip.nodeStreamOff", "Node Stream: OFF - click to follow selected node previews, including ImageOps live canvases"), "N"), n.appendChild(e._nodeStreamBtn), e._genBtn = document.createElement("button"), e._genBtn.type = "button", e._genBtn.className = "mjr-icon-btn", e._genBtn.setAttribute("aria-pressed", String(!!e._genSidebarEnabled));
let R = document.createElement("i");
R.className = "pi pi-info-circle", R.setAttribute("aria-hidden", "true"), e._genBtn.appendChild(R), n.appendChild(e._genBtn), e._updateGenBtnUI(), e._popoutBtn = document.createElement("button"), e._popoutBtn.type = "button", e._popoutBtn.className = "mjr-icon-btn";
let z = l("tooltip.popOutViewer", "Pop out viewer to separate window");
let z = s("tooltip.popOutViewer", "Pop out viewer to separate window");
e._popoutBtn.title = z, e._popoutBtn.setAttribute("aria-label", z), e._popoutBtn.setAttribute("aria-pressed", "false");
let B = document.createElement("i");
B.className = "pi pi-external-link", B.setAttribute("aria-hidden", "true"), e._popoutBtn.appendChild(B), n.appendChild(e._popoutBtn), e._captureBtn = document.createElement("button"), e._captureBtn.type = "button", e._captureBtn.className = "mjr-icon-btn";
let te = l("tooltip.captureView", "Save view as image");
let te = s("tooltip.captureView", "Save view as image");
e._captureBtn.title = te, e._captureBtn.setAttribute("aria-label", te);
let ne = document.createElement("i");
ne.className = "pi pi-download", ne.setAttribute("aria-hidden", "true"), e._captureBtn.appendChild(ne), n.appendChild(e._captureBtn);
let H = document.createElement("div");
H.className = "mjr-mfv-toolbar-sep", H.style.marginLeft = "auto", H.setAttribute("aria-hidden", "true"), n.appendChild(H), e._settingsBtn = document.createElement("button"), e._settingsBtn.type = "button", e._settingsBtn.className = "mjr-icon-btn mjr-mfv-settings-btn";
let re = l("tooltip.nodeParams", "Node Parameters");
let re = s("tooltip.nodeParams", "Node Parameters");
e._settingsBtn.title = re, e._settingsBtn.setAttribute("aria-label", re), e._settingsBtn.setAttribute("aria-pressed", "false");
let ie = document.createElement("i");
ie.className = "pi pi-sliders-h", ie.setAttribute("aria-hidden", "true"), e._settingsBtn.appendChild(ie), n.appendChild(e._settingsBtn), e._runHandle = Xt(), n.appendChild(e._runHandle.el), e._majoorSettingsBtn = document.createElement("button"), e._majoorSettingsBtn.type = "button", e._majoorSettingsBtn.className = "mjr-icon-btn mjr-mfv-majoor-settings-btn";
let U = l("tooltip.openMajoorSettings", "Open Majoor Assets Manager settings");
let U = s("tooltip.openMajoorSettings", "Open Majoor Assets Manager settings");
e._majoorSettingsBtn.title = U, e._majoorSettingsBtn.setAttribute("aria-label", U);
let ae = document.createElement("i");
return ae.className = "pi pi-cog", ae.setAttribute("aria-hidden", "true"), e._majoorSettingsBtn.appendChild(ae), n.appendChild(e._majoorSettingsBtn), e._handleDocClick = (t) => {
@@ -2455,7 +2456,7 @@ function mn(e, t) {
}
function hn(e) {
if (!e._contentWrapper) return;
let t = h.MFV_SIDEBAR_POSITION || "right";
let t = x.MFV_SIDEBAR_POSITION || "right";
e._contentWrapper.setAttribute("data-sidebar-pos", t), e._sidebar?.el && e._contentEl && (t === "left" ? e._contentWrapper.insertBefore(e._sidebar.el, e._contentEl) : e._contentWrapper.appendChild(e._sidebar.el));
}
function gn(e) {
@@ -2645,7 +2646,7 @@ function kn(e, t) {
if (!e._liveBtn) return;
let n = !!t;
e._liveBtn.classList.toggle("mjr-live-active", n);
let r = n ? l("tooltip.liveStreamOn", "Live Stream: ON - follows final generation outputs after execution") : l("tooltip.liveStreamOff", "Live Stream: OFF - click to follow final generation outputs"), i = U(r, "L");
let r = n ? s("tooltip.liveStreamOn", "Live Stream: ON - follows final generation outputs after execution") : s("tooltip.liveStreamOff", "Live Stream: OFF - click to follow final generation outputs"), i = U(r, "L");
e._liveBtn.setAttribute("aria-pressed", String(n)), e._liveBtn.setAttribute("aria-label", i);
let a = document.createElement("i");
a.className = n ? "pi pi-circle-fill" : "pi pi-circle", a.setAttribute("aria-hidden", "true"), e._liveBtn.replaceChildren(a), e._liveBtn.title = i;
@@ -2653,7 +2654,7 @@ function kn(e, t) {
function An(e, t) {
if (e._previewActive = !!t, !e._previewBtn) return;
e._previewBtn.classList.toggle("mjr-preview-active", e._previewActive);
let n = e._previewActive ? l("tooltip.previewStreamOn", "KSampler Preview: ON - streams sampler denoising frames during execution") : l("tooltip.previewStreamOff", "KSampler Preview: OFF - click to stream sampler denoising frames"), r = U(n, "K");
let n = e._previewActive ? s("tooltip.previewStreamOn", "KSampler Preview: ON - streams sampler denoising frames during execution") : s("tooltip.previewStreamOff", "KSampler Preview: OFF - click to stream sampler denoising frames"), r = U(n, "K");
e._previewBtn.setAttribute("aria-pressed", String(e._previewActive)), e._previewBtn.setAttribute("aria-label", r);
let i = document.createElement("i");
i.className = e._previewActive ? "pi pi-eye" : "pi pi-eye-slash", i.setAttribute("aria-hidden", "true"), e._previewBtn.replaceChildren(i), e._previewBtn.title = r;
@@ -2730,7 +2731,7 @@ function Pn(e) {
function Fn(e, t) {
if (e._nodeStreamActive = !!t, e._nodeStreamActive || e.setNodeStreamSelection?.(null), !e._nodeStreamBtn) return;
e._nodeStreamBtn.classList.toggle("mjr-nodestream-active", e._nodeStreamActive);
let n = e._nodeStreamActive ? l("tooltip.nodeStreamOn", "Node Stream: ON - follows the selected node preview when frontend media exists") : l("tooltip.nodeStreamOff", "Node Stream: OFF - click to follow selected node previews, including ImageOps live canvases"), r = U(n, "N");
let n = e._nodeStreamActive ? s("tooltip.nodeStreamOn", "Node Stream: ON - follows the selected node preview when frontend media exists") : s("tooltip.nodeStreamOff", "Node Stream: OFF - click to follow selected node previews, including ImageOps live canvases"), r = U(n, "N");
e._nodeStreamBtn.setAttribute("aria-pressed", String(e._nodeStreamActive)), e._nodeStreamBtn.setAttribute("aria-label", r);
let i = document.createElement("i");
i.className = "pi pi-sitemap", i.setAttribute("aria-hidden", "true"), e._nodeStreamBtn.replaceChildren(i), e._nodeStreamBtn.title = r;
@@ -2841,7 +2842,7 @@ function Wn(e, t) {
function Gn(e, t) {
e._desktopPopoutUnsupported = !0, $("electron-in-app-fallback", { message: t?.message || String(t || "unknown error") }, "warn"), e._setDesktopExpanded(!0);
try {
r(l("toast.popoutElectronInAppFallback", "Desktop PiP is unavailable here. Viewer expanded inside the app instead."), "warning", 4500);
r(s("toast.popoutElectronInAppFallback", "Desktop PiP is unavailable here. Viewer expanded inside the app instead."), "warning", 4500);
} catch (e) {
console.debug?.(e);
}
@@ -3055,7 +3056,7 @@ function er(e) {
if (!e._popoutBtn) return;
let t = e._isPopped || e._desktopExpanded;
e.element && e.element.classList.toggle("mjr-mfv--popped", t), e._popoutBtn.classList.toggle("mjr-popin-active", t);
let n = e._popoutBtn.querySelector("i") || document.createElement("i"), r = t ? l("tooltip.popInViewer", "Return to floating panel") : l("tooltip.popOutViewer", "Pop out viewer to separate window");
let n = e._popoutBtn.querySelector("i") || document.createElement("i"), r = t ? s("tooltip.popInViewer", "Return to floating panel") : s("tooltip.popOutViewer", "Pop out viewer to separate window");
n.className = t ? "pi pi-sign-in" : "pi pi-external-link", e._popoutBtn.title = r, e._popoutBtn.setAttribute("aria-label", r), e._popoutBtn.setAttribute("aria-pressed", String(t)), e._popoutBtn.contains(n) || e._popoutBtn.replaceChildren(n);
}
// ui/features/viewer/floatingViewerPanel.ts
@@ -3315,7 +3316,7 @@ function lr(e, t, n, r, i, a, o) {
}
async function ur(e) {
if (!e._contentEl) return;
e._captureBtn && (e._captureBtn.disabled = !0, e._captureBtn.setAttribute("aria-label", l("tooltip.capturingView", "Capturing...")));
e._captureBtn && (e._captureBtn.disabled = !0, e._captureBtn.setAttribute("aria-label", s("tooltip.capturingView", "Capturing...")));
let t = e._contentEl.clientWidth || 480, n = e._contentEl.clientHeight || 360, r = n;
if (e._mode === W.SIMPLE && e._mediaA && e._genInfoSelections.size) {
let i = document.createElement("canvas");
@@ -3393,7 +3394,7 @@ async function ur(e) {
} catch (e) {
console.warn("[MFV] download failed:", e);
} finally {
e._captureBtn && (e._captureBtn.disabled = !1, e._captureBtn.setAttribute("aria-label", l("tooltip.captureView", "Save view as image")));
e._captureBtn && (e._captureBtn.disabled = !1, e._captureBtn.setAttribute("aria-label", s("tooltip.captureView", "Save view as image")));
}
}
// ui/features/viewer/floatingViewerLoader.ts
@@ -3408,8 +3409,8 @@ function pr(e, t, { autoMode: n = !1 } = {}) {
(async () => {
try {
let n = await fe(e._mediaA, {
getAssetMetadata: m,
getFileMetadataScoped: g
getAssetMetadata: f,
getFileMetadataScoped: u
});
if (e._refreshGen !== t) return;
n && typeof n == "object" && (e._mediaA = n, e._refresh());
@@ -3426,8 +3427,8 @@ function mr(e, t, n) {
if (!e) return e;
try {
return await fe(e, {
getAssetMetadata: m,
getFileMetadataScoped: g
getAssetMetadata: f,
getFileMetadataScoped: u
}) || e;
} catch {
return e;
@@ -3450,8 +3451,8 @@ function hr(e, t, n, r, i) {
if (!e) return e;
try {
return await fe(e, {
getAssetMetadata: m,
getFileMetadataScoped: g
getAssetMetadata: f,
getFileMetadataScoped: u
}) || e;
} catch {
return e;
@@ -1,5 +1,6 @@
import { Hn as e, t } from "./config-D98p5fVn.js";
import { t as n } from "./floatingViewerManager-DbOx9dhE.js";
import { Vn as e } from "./client-BVwNDRIx.js";
import { t } from "./config-BFDzOF1N.js";
import { t as n } from "./floatingViewerManager-Dn53ZRsL.js";
import { r } from "./events-1iwo5blr.js";
// ui/features/viewer/LiveStreamTracker.ts
var i = !1, a = null, o = null, s = null, c = null, l = null, u = null, d = null, f = null, p = 0, m = 0, h = !1, g = 400, _ = "kj_preview_override", v = /* @__PURE__ */ new Set([
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,9 @@
import { Gt as e, T as t, Xn as n, an as r, ar as i, f as a, s as o, u as s } from "./config-D98p5fVn.js";
import { a as c, i as l, r as u } from "./floatingViewerManager-DbOx9dhE.js";
import { Wt as e, Yn as t, d as n, in as r, ir as i, l as a, o, w as s } from "./client-BVwNDRIx.js";
import { a as c, i as l, r as u } from "./floatingViewerManager-Dn53ZRsL.js";
import { i as d, r as f, t as p } from "./events-1iwo5blr.js";
import { a as ee, i as m, n as h, r as g, t as _ } from "./TagsEditor-C-qX3Ns8.js";
import { $ as te, A as v, B as ne, C as y, D as b, E as x, H as S, J as re, K as C, M as w, O as T, P as E, Q as D, S as O, T as k, U as A, V as ie, W as j, X as M, Y as N, b as ae, gt as P, ht as F, j as I, k as L, lt as oe, mt as R, ot as se, pt as z, q as B, rt as ce, st as V, w as le, z as H } from "./mjr-primevue-C07IsEdU.js";
import { C as U, _ as W, a as ue, b as de, c as G, d as fe, f as pe, g as me, h as he, i as ge, l as _e, m as ve, n as ye, o as K, p as be, r as xe, s as q, t as Se, u as Ce, v as we, x as Te, y as Ee } from "./genInfo-BlwZQHxb.js";
import { a as ee, i as m, n as h, r as g, t as _ } from "./TagsEditor-BxqU2WEs.js";
import { $ as te, A as v, B as ne, C as y, D as b, E as x, H as S, J as re, K as C, M as w, O as T, P as E, Q as D, S as O, T as k, U as A, V as ie, W as j, X as M, Y as N, b as ae, gt as P, ht as F, j as I, k as L, lt as oe, mt as R, ot as se, pt as z, q as B, rt as ce, st as V, w as le, z as H } from "./mjr-primevue-CE8VjDUg.js";
import { C as U, _ as W, a as ue, b as de, c as G, d as fe, f as pe, g as me, h as he, i as ge, l as _e, m as ve, n as ye, o as K, p as be, r as xe, s as q, t as Se, u as Ce, v as we, x as Te, y as Ee } from "./genInfo-BQayEE-N.js";
// ui/vue/components/viewer/FloatingViewerHost.vue
var De = /* @__PURE__ */ E({
__name: "FloatingViewerHost",
@@ -1376,7 +1376,7 @@ var Re = {
}, _n = /* @__PURE__ */ E({
__name: "StudioViewer",
setup(c) {
let l = V(null), u = V(null), f = V(null), te = V(null), v = V(!0), S = V("generation"), E = V(!1), A = V(1), N = oe(null), ae = oe(null), F = null, B = k(() => Q.assets[Q.index] ?? null), H = k(() => N.value ?? B.value), U = k(() => K(B.value)), W = k(() => B.value ? n(B.value) : ""), G = k(() => Q.assets.length), fe = k(() => xe(H.value)), pe = k(() => ue(H.value, ae.value)), me = k(() => Number(ae.value?.frame_count) || null), he = k(() => Number(H.value?.rating) || 0), _e = k(() => Array.isArray(H.value?.tags) ? H.value.tags : []), be = k(() => {
let l = V(null), u = V(null), f = V(null), te = V(null), v = V(!0), S = V("generation"), E = V(!1), A = V(1), N = oe(null), ae = oe(null), F = null, B = k(() => Q.assets[Q.index] ?? null), H = k(() => N.value ?? B.value), U = k(() => K(B.value)), W = k(() => B.value ? t(B.value) : ""), G = k(() => Q.assets.length), fe = k(() => xe(H.value)), pe = k(() => ue(H.value, ae.value)), me = k(() => Number(ae.value?.frame_count) || null), he = k(() => Number(H.value?.rating) || 0), _e = k(() => Array.isArray(H.value?.tags) ? H.value.tags : []), be = k(() => {
let { start: e, end: t } = ge(G.value, Q.index);
return Q.assets.slice(e, t).map((t, n) => ({
asset: t,
@@ -1386,7 +1386,7 @@ var Re = {
if (!J.value) return null;
let e = we.value, t = Q.assets, n = Q.index;
return e != null && e !== n && t[e] && K(t[e]) === U.value ? e : de(t, n, 1, (e) => K(e) === U.value);
}), X = k(() => Y.value != null), Z = k(() => Y.value == null ? null : Q.assets[Y.value]), ke = k(() => q.compare !== "off" && !!Z.value), Ae = k(() => ke.value && Z.value ? n(Z.value) : null), je = k(() => ke.value && q.compare === "subtract" && U.value === "image" ? Ae.value : null), Ne = k(() => J.value && (Ee(q) || je.value) ? `url(#${Ce})` : ""), Pe = k(() => J.value && (q.zebra || q.scopes !== "off"));
}), X = k(() => Y.value != null), Z = k(() => Y.value == null ? null : Q.assets[Y.value]), ke = k(() => q.compare !== "off" && !!Z.value), Ae = k(() => ke.value && Z.value ? t(Z.value) : null), je = k(() => ke.value && q.compare === "subtract" && U.value === "image" ? Ae.value : null), Ne = k(() => J.value && (Ee(q) || je.value) ? `url(#${Ce})` : ""), Pe = k(() => J.value && (q.zebra || q.scopes !== "off"));
function Re() {
return U.value === "image" ? u.value?.getSource?.() ?? null : U.value === "video" ? f.value?.getSource?.() ?? null : null;
}
@@ -1464,17 +1464,17 @@ var Re = {
try {
let n = await Se(e, {
getAssetMetadata: o,
getFileMetadataScoped: s,
getFileMetadataScoped: a,
signal: t?.signal
});
F === t && B.value === e && (N.value = n && typeof n == "object" ? n : e);
} catch (e) {
console.debug?.(e);
}
let n = K(e);
if ((n === "video" || n === "audio") && e?.id != null) try {
let n = await a(e.id, t ? { signal: t.signal } : {});
F === t && n?.ok && (ae.value = n.data || null);
let r = K(e);
if ((r === "video" || r === "audio") && e?.id != null) try {
let r = await n(e.id, t ? { signal: t.signal } : {});
F === t && r?.ok && (ae.value = r.data || null);
} catch (e) {
console.debug?.(e);
}
@@ -1496,17 +1496,17 @@ var Re = {
let e = document.createElement("a");
e.href = W.value, e.download = String(B.value?.filename || ""), e.click();
}
async function tt(n) {
let i = B.value;
if (!i) return;
let a = Math.max(0, Math.min(5, n)), o = await t(i, a);
o?.ok ? (i.rating = a, N.value = N.value ? {
async function tt(t) {
let n = B.value;
if (!n) return;
let i = Math.max(0, Math.min(5, t)), a = await s(n, i);
a?.ok ? (n.rating = i, N.value = N.value ? {
...N.value,
rating: a
} : i, window.dispatchEvent(new CustomEvent(p, { detail: {
assetId: i.id == null ? "" : String(i.id),
rating: a
} }))) : e(o?.error || r("toast.ratingUpdateFailed", "Rating update failed"), "error");
rating: i
} : n, window.dispatchEvent(new CustomEvent(p, { detail: {
assetId: n.id == null ? "" : String(n.id),
rating: i
} }))) : e(a?.error || r("toast.ratingUpdateFailed", "Rating update failed"), "error");
}
function rt(e) {
return !!e?.closest?.("input, textarea, select, [contenteditable='true']");
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
// ui/app/config.ts
var e = Object.freeze({
DEBUG_SAFE_CALL: !1,
DEBUG_SAFE_LISTENERS: !1,
DEBUG_VIEWER: !1,
GRID_MIN_SIZE: 150,
FEED_GRID_MIN_SIZE: 120,
GRID_GAP: 10,
GRID_SHOW_BADGES_EXTENSION: !0,
GRID_SHOW_BADGES_RATING: !0,
GRID_SHOW_BADGES_TAGS: !0,
GRID_SHOW_DETAILS: !0,
GRID_SHOW_DETAILS_FILENAME: !0,
GRID_SHOW_DETAILS_DATE: !0,
GRID_SHOW_DETAILS_DIMENSIONS: !0,
GRID_SHOW_DETAILS_GENTIME: !0,
GRID_SHOW_HOVER_INFO: !0,
GRID_SHOW_WORKFLOW_DOT: !0,
WORKFLOW_GRID_GROUP_BY: "model",
GRID_VIDEO_AUTOPLAY_MODE: "hover",
FEED_SHOW_INFO: !0,
FEED_SHOW_FILENAME: !1,
FEED_SHOW_DIMENSIONS: !0,
FEED_SHOW_DATE: !0,
FEED_SHOW_GENTIME: !0,
FEED_SHOW_WORKFLOW_DOT: !1,
FEED_SHOW_BADGES_EXTENSION: !0,
FEED_SHOW_BADGES_RATING: !0,
FEED_SHOW_BADGES_TAGS: !0,
UI_CARD_HOVER_COLOR: "#3D3D3D",
UI_CARD_SELECTION_COLOR: "#4A90E2",
UI_RATING_COLOR: "#FF9500",
UI_TAG_COLOR: "#4A90E2",
BADGE_STAR_COLOR: "#FFD45A",
BADGE_IMAGE_COLOR: "#2196F3",
BADGE_VIDEO_COLOR: "#9C27B0",
BADGE_AUDIO_COLOR: "#FF9800",
BADGE_MODEL3D_COLOR: "#4CAF50",
BADGE_DUPLICATE_ALERT_COLOR: "#FF1744",
DEFAULT_PAGE_SIZE: 80,
MAX_PAGE_SIZE: 2e3,
PREFETCH_NEXT_PAGE: !0,
PREFETCH_NEXT_PAGE_DELAY_MS: 700,
SEARCH_DEFAULT_LIMIT: 500,
INFINITE_SCROLL_ENABLED: !0,
INFINITE_SCROLL_ROOT_MARGIN: "800px",
INFINITE_SCROLL_THRESHOLD: .01,
BOTTOM_GAP_PX: 80,
STATUS_POLL_INTERVAL: 5e3,
AUTO_SCAN_ON_STARTUP: !1,
EXECUTION_GROUPING_ENABLED: !0,
EXECUTION_IDLE_GRACE_MS: 6e3,
DEFER_GRID_FETCH_DURING_EXECUTION: !1,
VIEWER_PAUSE_DURING_EXECUTION: !1,
FLOATING_VIEWER_PAUSE_DURING_EXECUTION: !1,
MFV_SIDEBAR_POSITION: "right",
MFV_LIVE_DEFAULT: !0,
MFV_PREVIEW_DEFAULT: !0,
MFV_LIVE_AUTO_OPEN: !1,
MFV_PREVIEW_AUTO_OPEN: !1,
MFV_NODE_STREAM_AUTO_OPEN: !1,
MFV_PREVIEW_METHOD: "auto",
MFV_KJ_PREVIEW_OVERRIDE_ENABLED: !0,
MFV_TOPBAR_BUTTON: !0,
RT_HYDRATE_CONCURRENCY: 2,
RT_HYDRATE_QUEUE_MAX: 100,
RT_HYDRATE_SEEN_MAX: 2e4,
RT_HYDRATE_PRUNE_BUDGET: 250,
RT_HYDRATE_SEEN_TTL_MS: 6e5,
WORKFLOW_MINIMAP_ENABLED: !0,
WATCHER_DEBOUNCE_MS: 3e3,
WATCHER_DEDUPE_TTL_MS: 3e3,
WATCHER_MAX_PENDING: 500,
WATCHER_MIN_SIZE: 100,
WATCHER_MAX_SIZE: 4294967296,
DB_TIMEOUT_MS: 5e3,
DB_MAX_CONNECTIONS: 10,
DB_QUERY_TIMEOUT_MS: 1e3,
SEARCH_REQUEST_LIMIT: 500,
DELETE_CONFIRMATION: !0,
DEBUG_VERBOSE_ERRORS: !1,
SIDEBAR_ASSET_BADGE_ENABLED: !0
}), t = { ...e };
export { e as n, t };
@@ -1,5 +1,6 @@
import { t as e } from "./rolldown-runtime-CGnMp29N.js";
import { Gt as t, c as n, jn as r, t as i } from "./config-D98p5fVn.js";
import { An as t, Wt as n, s as r } from "./client-BVwNDRIx.js";
import { t as i } from "./config-BFDzOF1N.js";
import { r as a } from "./events-1iwo5blr.js";
// ui/utils/logging.ts
function o(e, ...t) {
@@ -7,15 +8,15 @@ function o(e, ...t) {
i.DEBUG_VERBOSE_ERRORS && console.debug(e, ...t);
} catch {}
}
function s(e, n = "Majoor", { showToast: r = !1, toastType: a = "error" } = {}) {
function s(e, t = "Majoor", { showToast: r = !1, toastType: a = "error" } = {}) {
let o = e?.message || String(e || "Unknown error");
try {
i.DEBUG_VERBOSE_ERRORS ? console.error(`[Majoor][${n}]`, o, e) : console.debug(`[Majoor][${n}]`, o);
i.DEBUG_VERBOSE_ERRORS ? console.error(`[Majoor][${t}]`, o, e) : console.debug(`[Majoor][${t}]`, o);
} catch (e) {
console.debug?.(e);
}
if (r && i.DEBUG_VERBOSE_ERRORS) try {
t(`${n}: ${o}`, a, 4e3);
n(`${t}: ${o}`, a, 4e3);
} catch (e) {
console.debug?.(e);
}
@@ -201,7 +202,7 @@ var he = /* @__PURE__ */ e({
teardownFloatingViewerManager: () => Ue
}), S = null, ge = null;
async function _e() {
return S || (ge ||= import("./FloatingViewer-C_TFPglf.js").then((e) => (S = e.FloatingViewer, S)), ge);
return S || (ge ||= import("./FloatingViewer-DQK63pBw.js").then((e) => (S = e.FloatingViewer, S)), ge);
}
var C = null, ve = null;
async function ye() {
@@ -334,7 +335,7 @@ function Ce(e) {
async function H(e, t = []) {
if (!e.length || !T) return;
I();
let r = ++P, i = typeof AbortController < "u" ? new AbortController() : null;
let n = ++P, i = typeof AbortController < "u" ? new AbortController() : null;
N = i;
try {
let a = T.getPinnedSlots(), o = a.size > 0, s = T._mode, c = s === w.GRID, l = s === w.AB || s === w.SIDE, u = c ? 4 : 2, d = e.slice(0, u);
@@ -345,11 +346,11 @@ async function H(e, t = []) {
let e = Ce(d[0]);
e && (d = [d[0], e]);
}
let f = L(), p = f?._mjrGetAssets?.() || f?._mjrGetSelectedAssets?.() || [], m = new Map([...p, ...t].filter((e) => e && e.kind !== "folder").map((e) => [String(e.id), e])), h = d.filter((e) => Number.isSafeInteger(Number(e)) && Number(e) > 0), g = h.length ? await n(h, i ? { signal: i.signal } : {}) : {
let f = L(), p = f?._mjrGetAssets?.() || f?._mjrGetSelectedAssets?.() || [], m = new Map([...p, ...t].filter((e) => e && e.kind !== "folder").map((e) => [String(e.id), e])), h = d.filter((e) => Number.isSafeInteger(Number(e)) && Number(e) > 0), g = h.length ? await r(h, i ? { signal: i.signal } : {}) : {
ok: !0,
data: []
};
if (i?.signal.aborted || P !== r || !T) return;
if (i?.signal.aborted || P !== n || !T) return;
let _ = new Map((g?.ok && Array.isArray(g.data) ? g.data : []).map((e) => [String(e.id), e])), v = d.map((e) => _.get(String(e)) || m.get(String(e))).filter(Boolean);
if (!v.length) return;
if (c) {
@@ -434,7 +435,7 @@ function Oe() {
}
function Y() {
if (!G) try {
K = r(De, { includePointerFallback: !0 }), G = typeof K == "function";
K = t(De, { includePointerFallback: !0 }), G = typeof K == "function";
} catch (e) {
console.debug?.("[MFV] _bindNodeSelectionListener error", e);
}
@@ -1,7 +1,8 @@
import { an as e, sr as t, t as n } from "./config-D98p5fVn.js";
import { _ as r, f as i, p as a } from "./generationSectionState-BPjCPC13.js";
import { in as e, or as t } from "./client-BVwNDRIx.js";
import { t as n } from "./config-BFDzOF1N.js";
import { _ as r, f as i, p as a } from "./generationSectionState-DwIh8vyW.js";
import { r as o } from "./events-1iwo5blr.js";
import { $ as s, B as c, C as l, D as u, E as d, H as ee, J as te, M as f, O as p, P as m, Q as h, S as g, T as _, V as v, W as y, X as ne, b, gt as x, ht as S, j as C, k as w, mt as T, pt as E, q as re, st as D } from "./mjr-primevue-C07IsEdU.js";
import { $ as s, B as c, C as l, D as u, E as d, H as ee, J as te, M as f, O as p, P as m, Q as h, S as g, T as _, V as v, W as y, X as ne, b, gt as x, ht as S, j as C, k as w, mt as T, pt as E, q as re, st as D } from "./mjr-primevue-CE8VjDUg.js";
// ui/features/viewer/model3dCore.ts
function ie(e, t) {
if (e && typeof t == "function") try {
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
import { B as e, L as t, R as n, T as r, X as i, at as a, dt as o, et as s, it as c, nt as l, ot as u, rt as d, st as f, tt as p, ut as m } from "./mjr-primevue-C07IsEdU.js";
// node_modules/pinia/dist/pinia.js
import { B as e, L as t, R as n, T as r, X as i, at as a, dt as o, et as s, it as c, nt as l, ot as u, rt as d, st as f, tt as p, ut as m } from "./mjr-primevue-CE8VjDUg.js";
// ../../../../../../../../ComfyUI-Installs/ComfyUI (from Portable)/ComfyUI/custom_nodes/ComfyUI-Majoor-AssetsManager/node_modules/pinia/dist/pinia.js
var h = typeof window < "u", g, _ = (e) => g = e, v = () => t() && n(y) || g, y = Symbol();
function b(e) {
return e && typeof e == "object" && Object.prototype.toString.call(e) === "[object Object]" && typeof e.toJSON != "function";
@@ -1,8 +1,8 @@
import { An as e, Hn as t, Sn as n, _n as r, an as i, dn as a, pn as o, xn as s } from "./config-D98p5fVn.js";
import { Vn as e, bn as t, fn as n, gn as r, in as i, kn as a, un as o, xn as s } from "./client-BVwNDRIx.js";
import { a as c, n as l } from "./graphTraversal-DTdQWkn5.js";
import { C as u, S as d, b as f, x as p, y as m } from "./generationSectionState-BPjCPC13.js";
import { _ as ee, a as te, c as ne, d as re, f as ie, g as ae, h as oe, i as se, l as ce, m as le, n as ue, o as de, p as fe, r as pe, s as me, t as he, u as ge } from "./mjr-primevue-C07IsEdU.js";
import { t as _e } from "./mjr-vue-vendor-CEDOnn2t.js";
import { C as u, S as d, b as f, x as p, y as m } from "./generationSectionState-DwIh8vyW.js";
import { _ as ee, a as te, c as ne, d as re, f as ie, g as ae, h as oe, i as se, l as ce, m as le, n as ue, o as de, p as fe, r as pe, s as me, t as he, u as ge } from "./mjr-primevue-CE8VjDUg.js";
import { t as _e } from "./mjr-vue-vendor-pJNm95sb.js";
// ui/features/viewer/floatingViewerProgress.ts
var h = "progress-update", g = "__MJR_MFV_PROGRESS_SERVICE__";
function ve() {
@@ -14,7 +14,7 @@ function ye(e, t) {
return n.detail = t, n;
}
var be = class {
constructor(e, t = () => n()) {
constructor(e, t = () => s()) {
this.id = String(e || ""), this.promptApi = null, this.executedNodeIds = [], this.totalNodes = 0, this.currentlyExecuting = null, this.errorDetails = null, this._getApp = typeof t == "function" ? t : () => null;
}
setPrompt(e) {
@@ -63,8 +63,8 @@ var be = class {
this.errorDetails = e || null;
}
}, xe = class extends EventTarget {
constructor({ getApi: e = (e) => s(e), getApp: r = () => n(), waitForApi: i = (e) => t(e) } = {}) {
super(), this._getApi = e, this._getApp = r, this._waitForApi = i, this.promptsMap = /* @__PURE__ */ new Map(), this.currentExecution = null, this.lastQueueRemaining = 0, this._api = null, this._listenerEntries = [], this._initPromise = null, this._hydrations = /* @__PURE__ */ new Map();
constructor({ getApi: n = (e) => t(e), getApp: r = () => s(), waitForApi: i = (t) => e(t) } = {}) {
super(), this._getApi = n, this._getApp = r, this._waitForApi = i, this.promptsMap = /* @__PURE__ */ new Map(), this.currentExecution = null, this.lastQueueRemaining = 0, this._api = null, this._listenerEntries = [], this._initPromise = null, this._hydrations = /* @__PURE__ */ new Map();
}
getSnapshot() {
return {
@@ -247,10 +247,10 @@ function b(e, t) {
}
}
function we(e, t) {
let r = String(t || "").trim();
if (!r) return !1;
let n = String(t || "").trim();
if (!n) return !1;
try {
return a(r, n());
return o(n, s());
} catch (e) {
return console.debug?.(e), !1;
}
@@ -321,7 +321,7 @@ var x = /* @__PURE__ */ new Map(), S = null;
async function C(e) {
let t = Array.from(new Set(w(e).map((e) => D(e)).filter(Boolean)));
if (t.length && t.filter((e) => !x.has(e)).length) try {
S ||= o("/object_info").then((e) => e?.ok ? e.json() : null).then((e) => {
S ||= n("/object_info").then((e) => e?.ok ? e.json() : null).then((e) => {
if (e && typeof e == "object") for (let [t, n] of Object.entries(e)) x.set(String(t), n);
return e;
}).catch(() => null), await S;
@@ -871,18 +871,18 @@ function dt(e, t, n = null) {
default: return _t(e);
}
}
function G(t, n, i = null) {
if (!t) return !1;
let a = String(t.type || "").toLowerCase();
if (a === "number" || a === "int" || a === "float") {
let e = Number(n);
if (Number.isNaN(e)) return !1;
let r = t.options ?? {}, i = r.min ?? -Infinity, o = r.max ?? Infinity, s = Math.min(o, Math.max(i, e));
(a === "int" || r.precision === 0 || r.round === 1) && (s = Math.round(s)), t.value = s;
} else t.value = a === "toggle" || a === "boolean" ? !!n : n;
function G(e, t, n = null) {
if (!e) return !1;
let i = String(e.type || "").toLowerCase();
if (i === "number" || i === "int" || i === "float") {
let n = Number(t);
if (Number.isNaN(n)) return !1;
let r = e.options ?? {}, a = r.min ?? -Infinity, o = r.max ?? Infinity, s = Math.min(o, Math.max(a, n));
(i === "int" || r.precision === 0 || r.round === 1) && (s = Math.round(s)), e.value = s;
} else e.value = i === "toggle" || i === "boolean" ? !!t : t;
try {
let n = r(), o = i ?? t?.parent ?? null, s = t.value;
t.callback?.(t.value, n, o, null, t), (a === "number" || a === "int" || a === "float") && (t.value = s), ft(t), e(o);
let t = r(), o = n ?? e?.parent ?? null, s = e.value;
e.callback?.(e.value, t, o, null, e), (i === "number" || i === "int" || i === "float") && (e.value = s), ft(e), a(o);
} catch (e) {
console.debug?.("[MFV] writeWidgetValue", e);
}
@@ -1325,11 +1325,11 @@ function Z() {
}
}
}
function Ot(e = n()) {
function Ot(e = s()) {
if (typeof document > "u") return !1;
Y?.root?.isConnected || (Y = Dt());
let t = u(e), r = () => Et(Y.body, t, String(Y.search.value || "").trim().toLowerCase());
Y.search.oninput = r, Y.search.value = "", r(), Y.root.hidden = !1;
let t = u(e), n = () => Et(Y.body, t, String(Y.search.value || "").trim().toLowerCase());
Y.search.oninput = n, Y.search.value = "", n(), Y.root.hidden = !1;
try {
window.dispatchEvent(new CustomEvent(J, { detail: { open: !0 } }));
} catch (e) {
@@ -1338,7 +1338,7 @@ function Ot(e = n()) {
return setTimeout(() => Y?.search?.focus?.(), 0), !0;
}
// ui/app/openMajoorSettings.ts
function Q(e = n()) {
function Q(e = s()) {
return Ot(e);
}
try {
+4494 -4389
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -1226,6 +1226,51 @@ POST /mjr/am/db/force-delete
---
### Trash Status
```http
GET /mjr/am/trash
```
**Purpose**: Report how many freedesktop trash entries came from Majoor's folders (output, input, custom roots). Linux/BSD only; the response never contains file paths. Rate-limited to 20 requests per 60 seconds.
**Response**:
```json
{
"ok": true,
"data": {
"supported": true,
"count": 3,
"bytes": 1048576
}
}
```
---
### Empty Trash
```http
POST /mjr/am/trash/empty
```
**Purpose**: Permanently remove the freedesktop trash entries whose original location was inside a Majoor folder. Entries from outside Majoor's folders, orphans, and symlink-reachable payloads are never touched; a trash directory is only used when it belongs to the current user and is not group/world-writable. Directories inside the trash are never removed (reported in `skipped_dirs`). Requires the same permission as asset deletion (`allow_delete` / `MAJOOR_ALLOW_DELETE=1`); rate-limited to 3 requests per 60 seconds.
**Response**:
```json
{
"ok": true,
"data": {
"removed": 3,
"bytes": 1048576,
"failed": [],
"skipped_dirs": 0
}
}
```
`failed` lists entries that could not be removed (`name` + `error`); the remaining entries are still removed. `skipped_dirs` counts directory payloads, which are left in place.
---
## Utilities
### Date Histogram
+19
View File
@@ -88,6 +88,25 @@ Open the Assets Manager panel and expand the **Index Status** section. Maintenan
| **Memory purge** | Unloads Majoor AI models, asks ComfyUI to unload loaded models, and clears torch cache when the queue is idle. |
| **Reset index** | Clears cached data inside the existing database and triggers a background rescan. Requires the database to be readable. |
| **Delete DB** | Force-deletes the database files from disk (bypassing all DB-dependent checks) and rebuilds from scratch. Works even when the database is corrupted. |
| **Empty trash** | Permanently removes the files Majoor sent to the freedesktop trash. Linux/BSD only; requires the delete permission (`allow_delete` / `MAJOOR_ALLOW_DELETE=1`). |
## Empty Trash
Majoor deletes files through the system trash (`send2trash`). On Linux and the BSDs those files land in the freedesktop trash (`~/.local/share/Trash` or `<mount>/.Trash-<uid>`), and on a headless server nothing ever clears it — so deleting assets never actually frees disk space.
The **Empty trash** button in the Status panel fixes that:
1. It counts only trash entries whose original location (recorded in each `.trashinfo` file) was inside one of Majoor's folders (output, input, or a custom root).
2. It asks for confirmation showing the count and total size.
3. On confirm it permanently deletes those entries — payload **and** metadata — via `POST /mjr/am/trash/empty`.
Notes:
- Selection is by original location, so files you trashed from those same folders with **other tools are included** and will be permanently deleted too.
- Entries from outside Majoor's folders, orphans without trash metadata, and anything reachable only through a symlink are never touched.
- **Directories in the trash are never removed.** Majoor only ever trashes files; a trashed directory (and its contents) is left in place and reported as skipped. Clear those by hand if needed.
- **Ownership requirement:** a trash directory is only used when it belongs to the user running ComfyUI and is not group- or world-writable. Trash state planted by another user on a shared mount is ignored.
- The button is hidden on non-freedesktop platforms (Windows/macOS) and disabled when deletion is disabled, because emptying shares the `allow_delete` gate with asset deletion.
## Reset Index
+1
View File
@@ -110,6 +110,7 @@ Safe Mode adds an explicit opt-in layer for operations that modify files or user
- Allows rating/tags writes while Safe Mode is enabled.
- `MAJOOR_ALLOW_DELETE=1`
- Enables asset deletion (disabled by default).
- Also gates emptying the freedesktop trash (`POST /mjr/am/trash/empty`), which permanently removes trashed files.
- `MAJOOR_ALLOW_RENAME=1`
- Enables asset rename (disabled by default).
- `MAJOOR_ALLOW_OPEN_IN_FOLDER=1`
@@ -0,0 +1,466 @@
"""Freedesktop trash discovery and emptying for Majoor-managed roots.
Majoor deletes through ``send2trash``; on freedesktop platforms that moves
files into the freedesktop trash (``<mount>/.Trash-<uid>/``, ``<mount>/.Trash/<uid>/``
or ``$XDG_DATA_HOME/Trash``). Headless servers never empty it, so curation
never frees disk.
This module finds, among the trash directories relevant to Majoor's roots,
only the entries whose original path (recorded in the ``.trashinfo`` ``Path=``
field) lies inside a Majoor root, and permanently removes those on request.
Everything else — foreign entries, malformed metadata, orphans and anything
reachable only through a symlink — is never touched.
Hardening rules:
- A trash directory (and its ``files/`` and ``info/`` subdirectories) is used
only when it is a real directory owned by the effective uid and not
group/world-writable, so trashed state planted by another user on a shared
mount is ignored.
- Removal never operates on absolute paths. The trash directory and its
``files/``/``info/`` subdirectories are opened with
``O_RDONLY|O_DIRECTORY|O_NOFOLLOW`` relative to their parent fd, ownership
and mode are re-checked via ``fstat``, and unlink targets are addressed by
bare name via ``dir_fd``. Directory payloads are never removed (Majoor only
trashes files); they are reported in ``skipped_dirs``.
All filesystem state is injected via parameters (``uid``, ``home``,
``xdg_data_home``, and ``platform`` for :func:`is_supported`) so tests can
point the service at synthetic trees under ``tmp_path``.
"""
from __future__ import annotations
import os
import stat
import sys
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import unquote
from ...shared import Result, get_logger
from ...shared import sanitize_error_message as _safe_error_message
logger = get_logger(__name__)
_SUPPORTED_PLATFORM_PREFIXES = ("linux", "freebsd", "openbsd", "netbsd")
_TRASH_INFO_HEADER = "[Trash Info]"
_INFO_SUFFIX = ".trashinfo"
_MAX_INFO_BYTES = 64 * 1024
_FORBIDDEN_WRITE_BITS = 0o022
_OPEN_DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
def is_supported(platform: str | None = None) -> bool:
"""Return True only on freedesktop platforms (Linux and the BSDs)."""
plat = sys.platform if platform is None else str(platform)
return any(plat.startswith(prefix) for prefix in _SUPPORTED_PLATFORM_PREFIXES)
@dataclass(frozen=True)
class TrashDir:
"""A validated trash directory with the topdir used for relative paths."""
path: Path
topdir: Path
info_dir: Path
files_dir: Path
@dataclass(frozen=True)
class TrashEntry:
"""One selected trash entry: payload/info names inside its trash dir."""
trash_dir: Path
payload_name: str
info_name: str
size: int
@dataclass(frozen=True)
class TrashScan:
"""Aggregated scan result: selected entry count, total bytes and entries."""
count: int
bytes: int
entries: tuple[TrashEntry, ...]
def _normalize_roots(roots: Iterable[Path | str] | None) -> list[Path]:
"""Resolve and deduplicate the Majoor roots; unresolvable roots are dropped."""
out: list[Path] = []
seen: set[str] = set()
for root in roots or []:
try:
resolved = Path(str(root)).resolve(strict=False)
except (OSError, ValueError, RuntimeError):
logger.debug("trash: skipping unresolvable root %r", root)
continue
key = str(resolved)
if key in seen:
continue
seen.add(key)
out.append(resolved)
return out
def _lexical_roots(roots: Iterable[Path | str] | None) -> list[Path]:
"""Absolute, lexically-normalised roots (symlinks NOT resolved)."""
out: list[Path] = []
seen: set[str] = set()
for root in roots or []:
normalized = Path(os.path.normpath(os.path.abspath(str(root))))
key = str(normalized)
if key in seen:
continue
seen.add(key)
out.append(normalized)
return out
def _mount_point(path: Path) -> Path:
"""Walk up to the mount point (topdir) owning *path*."""
current = Path(path)
while not os.path.ismount(current):
parent = current.parent
if parent == current:
return current
current = parent
return current
def _lstat_safe(path: Path) -> os.stat_result | None:
try:
return os.lstat(path)
except OSError:
return None
def _owned_dir_mode_ok(st: os.stat_result) -> bool:
return stat.S_ISDIR(st.st_mode) and (st.st_mode & _FORBIDDEN_WRITE_BITS) == 0
def _is_owned_dir(path: Path, uid: int) -> bool:
"""Real directory (never a symlink), owned by *uid*, not group/world-writable."""
st = _lstat_safe(path)
return st is not None and st.st_uid == uid and _owned_dir_mode_ok(st)
def _is_owned_dir_or_absent(path: Path, uid: int) -> bool:
"""Absent is fine; otherwise a real dir owned by *uid* with a safe mode."""
st = _lstat_safe(path)
return st is None or (st.st_uid == uid and _owned_dir_mode_ok(st))
def _fd_owned_safe(fd: int, uid: int) -> bool:
"""Re-check ownership/mode on an already-open directory fd."""
try:
st = os.fstat(fd)
except OSError:
return False
return st.st_uid == uid and _owned_dir_mode_ok(st)
def trash_dirs(
roots: Sequence[Path | str],
*,
uid: int | None = None,
home: Path | None = None,
xdg_data_home: Path | str | None = None,
) -> list[TrashDir]:
"""Enumerate the freedesktop trash dirs relevant to *roots*.
Scans the home trash (``$XDG_DATA_HOME/Trash``) plus, for the mount of
each root, ``<topdir>/.Trash-<uid>`` and the sticky-bit
``<topdir>/.Trash/<uid>``. Every candidate must be a real directory owned
by *uid* with no group/world write bits (likewise its ``files/`` and
``info/``), and results are deduplicated by real path.
"""
if uid is None:
uid = os.getuid()
if home is None:
home = Path.home()
if xdg_data_home is None:
env_xdg = os.environ.get("XDG_DATA_HOME")
xdg_data_home = Path(env_xdg) if env_xdg else Path(home) / ".local" / "share"
candidates: list[tuple[Path, Path]] = [(Path(xdg_data_home) / "Trash", Path(xdg_data_home))]
seen_topdirs: set[str] = set()
for root in _normalize_roots(roots):
topdir = _mount_point(root)
key = os.path.realpath(topdir)
if key in seen_topdirs:
continue
seen_topdirs.add(key)
candidates.append((topdir / f".Trash-{uid}", topdir))
sticky_parent = topdir / ".Trash"
st = _lstat_safe(sticky_parent)
if st is not None and stat.S_ISDIR(st.st_mode) and (st.st_mode & stat.S_ISVTX):
candidates.append((sticky_parent / str(uid), topdir))
out: list[TrashDir] = []
seen: set[str] = set()
for path, topdir in candidates:
key = os.path.realpath(path)
if key in seen:
continue
seen.add(key)
if not _is_owned_dir(path, uid):
continue
if not (_is_owned_dir_or_absent(path / "files", uid) and _is_owned_dir_or_absent(path / "info", uid)):
continue
out.append(
TrashDir(path=path, topdir=topdir, info_dir=path / "info", files_dir=path / "files")
)
return out
def _parse_trashinfo(info_path: Path) -> str | None:
"""Return the raw ``Path=`` value, or None when the file is malformed.
Reads at most 64 KiB; larger or undecodable files are treated as
malformed rather than parsing unbounded attacker-supplied content.
The open uses ``O_NOFOLLOW`` so a symlink swapped in after the lstat
fails and is treated as malformed.
"""
try:
info_fd = os.open(info_path, os.O_RDONLY | os.O_NOFOLLOW)
except OSError:
return None
try:
with os.fdopen(info_fd, "rb") as handle:
raw = handle.read(_MAX_INFO_BYTES + 1)
except OSError:
return None
if len(raw) > _MAX_INFO_BYTES:
return None
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
return None
if _TRASH_INFO_HEADER not in text:
return None
for line in text.splitlines():
if line.startswith("Path="):
value = line[len("Path=") :].strip()
return value or None
return None
def _is_within(candidate: Path, roots: list[Path]) -> bool:
"""Component-wise containment: *candidate* equals or sits under a root."""
for root in roots:
if candidate == root or root in candidate.parents:
return True
return False
def _payload_size(payload: Path) -> int:
"""lstat-based size of a payload; directories and missing files count 0."""
try:
st = os.lstat(payload)
except OSError:
return 0
if stat.S_ISDIR(st.st_mode):
return 0
return st.st_size
def _entry_from_info(
item: os.DirEntry,
trash_dir: TrashDir,
resolved_roots: list[Path],
lexical_roots: list[Path],
) -> TrashEntry | None:
"""Select a trash entry from one scandir item of the info directory."""
name = item.name
if not name.endswith(_INFO_SUFFIX):
return None
try:
st = os.lstat(item.path)
except OSError:
return None
if not stat.S_ISREG(st.st_mode):
return None
info_path = Path(item.path)
raw = _parse_trashinfo(info_path)
if not raw:
return None
original = unquote(raw)
if "\x00" in original:
return None
if os.path.isabs(original):
candidate = Path(os.path.normpath(original))
else:
candidate = Path(os.path.normpath(str(trash_dir.topdir / original)))
# Match either the lexical form or the realpath form of the original
# location against either root form, so entries recorded through a
# symlinked root prefix are still recognised. Selection only decides
# which trash payload to remove — it never touches the original path.
forms = [candidate]
real = Path(os.path.realpath(candidate))
if real != candidate:
forms.append(real)
selected = False
for form in forms:
if _is_within(form, resolved_roots) or _is_within(form, lexical_roots):
selected = True
break
if not selected:
return None
# The payload name is attacker-controlled: a crafted info basename
# (".trashinfo", "..trashinfo", ...) must never resolve the payload to
# the files dir, the trash dir, or anything outside files/.
stem = name[: -len(_INFO_SUFFIX)]
payload = trash_dir.files_dir / stem
if (
stem in ("", ".", "..")
or os.sep in stem
or (os.altsep and os.altsep in stem)
or "\x00" in stem
or payload.parent != trash_dir.files_dir
):
return None
return TrashEntry(
trash_dir=trash_dir.path,
payload_name=stem,
info_name=name,
size=_payload_size(payload),
)
def scan(
roots: Sequence[Path | str],
*,
uid: int | None = None,
home: Path | None = None,
xdg_data_home: Path | str | None = None,
) -> TrashScan:
"""Count (and collect) trash entries whose original path is inside *roots*."""
resolved = _normalize_roots(roots)
lexical = _lexical_roots(roots)
entries: list[TrashEntry] = []
for trash_dir in trash_dirs(resolved, uid=uid, home=home, xdg_data_home=xdg_data_home):
try:
listing = list(os.scandir(trash_dir.info_dir))
except OSError as exc:
logger.debug("trash scan: cannot list %s: %s", trash_dir.info_dir, exc)
continue
for item in listing:
try:
entry = _entry_from_info(item, trash_dir, resolved, lexical)
except (OSError, ValueError):
# A single hostile info entry must never fail the whole scan.
continue
if entry is not None:
entries.append(entry)
return TrashScan(count=len(entries), bytes=sum(e.size for e in entries), entries=tuple(entries))
def _remove_entry(entry: TrashEntry, *, uid: int) -> tuple[str, str | None]:
"""Remove one payload plus its trashinfo using fd-relative operations only.
Returns ``(outcome, error)`` where outcome is ``"removed"``,
``"skipped_dir"`` (directory payload — never removed) or ``"failed"``.
"""
try:
trash_fd = os.open(entry.trash_dir, _OPEN_DIR_FLAGS)
except OSError as exc:
return "failed", _safe_error_message(exc, "Failed to open trash directory")
try:
try:
files_fd = os.open("files", _OPEN_DIR_FLAGS, dir_fd=trash_fd)
except OSError as exc:
return "failed", _safe_error_message(exc, "Failed to open trash files directory")
try:
try:
info_fd = os.open("info", _OPEN_DIR_FLAGS, dir_fd=trash_fd)
except OSError as exc:
return "failed", _safe_error_message(exc, "Failed to open trash info directory")
try:
if not (
_fd_owned_safe(trash_fd, uid)
and _fd_owned_safe(files_fd, uid)
and _fd_owned_safe(info_fd, uid)
):
return "failed", "Trash directory failed ownership or mode re-check"
try:
st = os.lstat(entry.payload_name, dir_fd=files_fd)
except FileNotFoundError:
st = None
except OSError as exc:
return "failed", _safe_error_message(exc, "Failed to stat trashed entry")
if st is not None:
if stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode):
try:
os.unlink(entry.payload_name, dir_fd=files_fd)
except FileNotFoundError:
pass # vanished concurrently — treat as removed
except OSError as exc:
return "failed", _safe_error_message(exc, "Failed to remove trashed entry")
else:
# Majoor only ever trashes files; directory payloads
# (and any other non-file type) are skipped, not removed.
return "skipped_dir", None
try:
os.unlink(entry.info_name, dir_fd=info_fd)
except FileNotFoundError:
pass
except OSError as exc:
return "failed", _safe_error_message(exc, "Failed to remove trashinfo")
return "removed", None
finally:
os.close(info_fd)
finally:
os.close(files_fd)
finally:
os.close(trash_fd)
def empty(
roots: Sequence[Path | str],
*,
uid: int | None = None,
home: Path | None = None,
xdg_data_home: Path | str | None = None,
) -> Result[dict[str, Any]]:
"""Permanently remove every selected trash entry for *roots*.
Failures on individual entries are reported and skipped; the rest of the
batch still proceeds. Directory payloads are never removed and are
counted in ``skipped_dirs``.
"""
if uid is None:
uid = os.getuid()
scan_result = scan(roots, uid=uid, home=home, xdg_data_home=xdg_data_home)
removed = 0
freed = 0
skipped_dirs = 0
failed: list[dict[str, str]] = []
for entry in scan_result.entries:
outcome, error = _remove_entry(entry, uid=uid)
if outcome == "skipped_dir":
skipped_dirs += 1
continue
if error is not None:
failed.append({"name": entry.payload_name, "error": error})
continue
removed += 1
freed += entry.size
return Result.Ok(
{"removed": removed, "bytes": freed, "failed": failed, "skipped_dirs": skipped_dirs}
)
__all__ = [
"TrashDir",
"TrashEntry",
"TrashScan",
"empty",
"is_supported",
"scan",
"trash_dirs",
]
@@ -142,7 +142,7 @@ def _operation_static_gates() -> tuple[tuple[tuple[str, ...], str, str, str], ..
"Reset index is disabled by default. Enable 'allow_reset_index' in settings or set MAJOOR_ALLOW_RESET_INDEX=1.",
),
(
("delete", "asset_delete", "assets_delete"),
("delete", "asset_delete", "assets_delete", "trash_empty"),
"allow_delete",
"MAJOOR_ALLOW_DELETE",
"Delete is disabled by default. Set MAJOOR_ALLOW_DELETE=1 to enable asset deletion.",
@@ -14,13 +14,16 @@ from typing import Any
from aiohttp import web
from mjr_am_backend.config import INDEX_DB_PATH, get_runtime_output_root, is_vector_search_enabled
from mjr_am_backend.custom_roots import resolve_custom_root
from mjr_am_backend.custom_roots import list_custom_roots, resolve_custom_root
from mjr_am_backend.features.assets import trash_service
from mjr_am_backend.features.index.vector_runtime import maybe_unload_vector_runtime_after_use
from mjr_am_backend.runtime_activity import is_generation_busy
from mjr_am_backend.shared import FileKind, Result, get_logger
from ..core import (
_check_rate_limit,
_csrf_error,
_get_allowed_directories,
_json_response,
_read_json,
_require_operation_enabled,
@@ -263,6 +266,29 @@ def _emit_restore_status(step: str, level: str = "info", message: str | None = N
return archive_runtime.emit_restore_status(step, level, message, **extra)
def _trash_roots() -> list[Path]:
"""Resolve the Majoor roots (output, input, custom) for trash scanning."""
roots: list[Path] = []
try:
roots.extend(_get_allowed_directories())
except Exception:
logger.debug("trash: failed to enumerate allowed directories", exc_info=True)
try:
custom = list_custom_roots()
if custom.ok:
for item in custom.data or []:
raw = item.get("path") if isinstance(item, dict) else None
if not raw:
continue
try:
roots.append(Path(str(raw)).resolve(strict=False))
except (OSError, ValueError, RuntimeError):
continue
except Exception:
logger.debug("trash: failed to enumerate custom roots", exc_info=True)
return roots
async def _remove_with_retry(path: Path, attempts: int = 6) -> None:
return await archive_runtime.remove_with_retry(path, attempts)
@@ -1445,3 +1471,83 @@ def register_db_maintenance_routes(routes: web.RouteTableDef) -> None:
scans=len(scans_triggered if "scans_triggered" in locals() else []),
)
return _json_response(result)
@routes.get("/mjr/am/trash")
async def trash_status(request: web.Request):
"""Report how many trashed files came from Majoor's roots (no paths)."""
auth = _require_write_access(request)
if not auth.ok:
return _json_response(auth)
allowed, retry_after = _check_rate_limit(
request, "trash_status", max_requests=20, window_seconds=60
)
if not allowed:
return _json_response(
Result.Err(
"RATE_LIMITED",
f"Too many trash status requests. Retry in {int(retry_after or 60)} seconds.",
)
)
if not trash_service.is_supported():
return _json_response(Result.Ok({"supported": False, "count": 0, "bytes": 0}))
try:
scan = await asyncio.to_thread(lambda: trash_service.scan(_trash_roots()))
except Exception as exc:
return _json_response(
Result.Err("TRASH_ERROR", safe_error_message(exc, "Failed to scan trash"))
)
return _json_response(
Result.Ok({"supported": True, "count": scan.count, "bytes": scan.bytes})
)
@routes.post("/mjr/am/trash/empty")
async def trash_empty(request: web.Request):
"""Permanently remove trash entries that came from Majoor's roots."""
csrf = _csrf_error(request)
if csrf:
return _json_response(Result.Err("CSRF", csrf))
auth = _require_write_access(request)
if not auth.ok:
return _json_response(auth)
if not trash_service.is_supported():
return _json_response(
Result.Err(
"UNSUPPORTED",
"Trash operations are only supported on Linux and BSD platforms.",
)
)
allowed, retry_after = _check_rate_limit(
request, "trash_empty", max_requests=3, window_seconds=60
)
if not allowed:
return _json_response(
Result.Err(
"RATE_LIMITED",
f"Too many empty-trash requests. Retry in {int(retry_after or 60)} seconds.",
)
)
svc, error_result = await _require_services()
if error_result:
return _json_response(error_result)
prefs = await _resolve_security_prefs(svc if isinstance(svc, dict) else None)
op = _require_operation_enabled("trash_empty", prefs=prefs)
if not op.ok:
return _json_response(op)
try:
result = await asyncio.to_thread(lambda: trash_service.empty(_trash_roots()))
except Exception as exc:
result = Result.Err("TRASH_ERROR", safe_error_message(exc, "Failed to empty trash"))
await _audit_db_maintenance_write(
svc if isinstance(svc, dict) else {},
request,
"trash_empty",
"trash:empty",
result,
removed=int((result.data or {}).get("removed") or 0) if result.ok else 0,
bytes=int((result.data or {}).get("bytes") or 0) if result.ok else 0,
failed=len((result.data or {}).get("failed") or []) if result.ok else 0,
skipped_dirs=int((result.data or {}).get("skipped_dirs") or 0) if result.ok else 0,
)
return _json_response(result)
+51 -71
View File
@@ -9,20 +9,20 @@
"@tanstack/vue-virtual": "^3.13.39",
"pinia": "^4.0.3",
"primevue": "4.5.5",
"three": "0.186.1",
"three": "0.187.0",
"vue": "^3.5.44"
},
"devDependencies": {
"@comfyorg/comfyui-frontend-types": "^1.60.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.64.1",
"@types/node": "^26.6.4",
"@vitejs/plugin-vue": "^6.0.9",
"@vitest/coverage-v8": "^5.0.5",
"@types/node": "^26.6.6",
"@vitejs/plugin-vue": "^6.0.10",
"@vitest/coverage-v8": "^5.1.0",
"@vue/test-utils": "^2.5.1",
"eslint": "^10.12.0",
"happy-dom": "^21.0.4",
"knip": "^6.39.0",
"knip": "^6.44.1",
"oxlint": "^1.90.0",
"prettier": "^3.9.10",
"typescript": "^7.0.2",
@@ -1809,9 +1809,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.6.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1842,7 +1842,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1859,7 +1858,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1876,7 +1874,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1893,7 +1890,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1910,7 +1906,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1927,7 +1922,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1944,7 +1938,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1961,7 +1954,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1978,7 +1970,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1995,7 +1986,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2012,7 +2002,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2029,7 +2018,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2046,7 +2034,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2063,7 +2050,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2080,7 +2066,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2097,7 +2082,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2114,7 +2098,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2131,7 +2114,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2148,7 +2130,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2165,7 +2146,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2176,9 +2156,9 @@
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.9",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz",
"integrity": "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==",
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz",
"integrity": "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2189,19 +2169,19 @@
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"vue": "^3.2.25"
"vue": "^3.2.25 || ^3.6.0-rc.10"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.5.tgz",
"integrity": "sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.1.0.tgz",
"integrity": "sha512-OY9aShBQG5KOIw2zCo9fDoCHI9RHliNHJ//nM7m6jGg41Hyke9JOGWmeMlK74xYErsxMoK8mIB3gAeEyFVA7LA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/istanbul-lib-coverage": "^1.0.2",
"@vitest/istanbul-lib-report": "^1.0.2",
"@vitest/istanbul-lib-coverage": "^2.1.0",
"@vitest/istanbul-lib-report": "^2.1.0",
"ast-v8-to-istanbul": "^1.0.7",
"magicast": "^0.5.5",
"obug": "^3.0.0",
@@ -2212,8 +2192,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "5.0.5",
"vitest": "5.0.5"
"@vitest/browser": "5.1.0",
"vitest": "5.1.0"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -2222,9 +2202,9 @@
}
},
"node_modules/@vitest/istanbul-lib-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz",
"integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-2.1.0.tgz",
"integrity": "sha512-WOjuS+xcjSIy7ruYBQ+9BUcV7XZQ0FyVcbWbWxW9TRx/kLGx5SQaeFRIFDJxjNplb6YsT7SpKo2zuXXUo91OIQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2232,27 +2212,27 @@
}
},
"node_modules/@vitest/istanbul-lib-report": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz",
"integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-2.1.0.tgz",
"integrity": "sha512-+ZFta25nWtGCOBG9/IET8w1RO5Oawsd6H8Uf6AJ9EA/StFuKVtKAEJEJ6uRt84OH+T4YkI0ASmIHAhRA+0KVxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/istanbul-lib-coverage": "1.0.2"
"@vitest/istanbul-lib-coverage": "2.1.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@vitest/mocker": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz",
"integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz",
"integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.5",
"@vitest/spy": "5.1.0",
"estree-walker": "^3.0.3",
"magic-string": "^1.4.3"
},
@@ -2273,9 +2253,9 @@
}
},
"node_modules/@vitest/mocker/node_modules/magic-string": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
"integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz",
"integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2283,9 +2263,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz",
"integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz",
"integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3243,9 +3223,9 @@
}
},
"node_modules/knip": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.43.0.tgz",
"integrity": "sha512-8l3cCOg/EXoILO0qcSoMra49yQM91+lEIR4fu5Ko8rrArGX8xKdKZiGI9psPiY6rhLmfLERcMfFEByF0wli5CA==",
"version": "6.44.1",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.1.tgz",
"integrity": "sha512-Hbd8nvkIsNgFfXgC7Jsb5LHv7jj6VwA3tmfHd53XPX2z9Vc9RnAZqV0BIiP4OrtYqKwCoLseFHQZerb1bNlbSw==",
"dev": true,
"funding": [
{
@@ -4218,9 +4198,9 @@
}
},
"node_modules/three": {
"version": "0.186.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
"integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
"version": "0.187.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.187.0.tgz",
"integrity": "sha512-Qiu7R+Cmh8CstZsY1uJOSWnVlfo7ZtNLV7mF8Jltk3LB3AtDu9cRZlcnRKkAk8Y4z6KKw35z7Y7Urd7jWY8etA==",
"license": "MIT"
},
"node_modules/tinybench": {
@@ -4432,14 +4412,14 @@
}
},
"node_modules/vitest": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz",
"integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz",
"integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.5",
"@vitest/mocker": "5.1.0",
"chai": "^6.3.0",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
@@ -4465,12 +4445,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.5",
"@vitest/browser-preview": "5.0.5",
"@vitest/browser-playwright": "5.1.0",
"@vitest/browser-preview": "5.1.0",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.5",
"@vitest/coverage-v8": "5.0.5",
"@vitest/ui": "5.0.5",
"@vitest/coverage-istanbul": "5.1.0",
"@vitest/coverage-v8": "5.1.0",
"@vitest/ui": "5.1.0",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
+5 -5
View File
@@ -40,13 +40,13 @@
"@comfyorg/comfyui-frontend-types": "^1.60.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.64.1",
"@types/node": "^26.6.4",
"@vitejs/plugin-vue": "^6.0.9",
"@vitest/coverage-v8": "^5.0.5",
"@types/node": "^26.6.6",
"@vitejs/plugin-vue": "^6.0.10",
"@vitest/coverage-v8": "^5.1.0",
"@vue/test-utils": "^2.5.1",
"eslint": "^10.12.0",
"happy-dom": "^21.0.4",
"knip": "^6.39.0",
"knip": "^6.44.1",
"oxlint": "^1.90.0",
"prettier": "^3.9.10",
"typescript": "^7.0.2",
@@ -57,7 +57,7 @@
"@tanstack/vue-virtual": "^3.13.39",
"pinia": "^4.0.3",
"primevue": "4.5.5",
"three": "0.186.1",
"three": "0.187.0",
"vue": "^3.5.44"
}
}
@@ -1,6 +1,7 @@
import asyncio
import json
import os
import types
from pathlib import Path
from typing import Any, cast
@@ -8,9 +9,12 @@ import pytest
from aiohttp import web
from aiohttp.streams import StreamReader
from aiohttp.test_utils import make_mocked_request
from mjr_am_backend.features.assets import trash_service as _real_trash_service
from mjr_am_backend.routes.handlers import db_maintenance as m
from mjr_am_backend.shared import Result
ts_types = types.SimpleNamespace(TrashScan=_real_trash_service.TrashScan)
def _app():
app = web.Application()
@@ -476,3 +480,434 @@ async def test_db_backup_restore_rejects_oversized_json_when_content_length_is_s
payload = json.loads(resp.text)
assert payload.get("ok") is False
assert payload.get("code") == "INVALID_INPUT"
# ---------------------------------------------------------------------------
# Trash status / empty routes
# ---------------------------------------------------------------------------
class _FakeTrashService:
"""Standalone fake for the trash_service module used by the routes."""
def __init__(self, *, supported=True, scan=None, empty=None):
self.supported = supported
self.scan_result = scan if scan is not None else ts_types.TrashScan(0, 0, ())
self.empty_result = empty
self.empty_calls: list[list] = []
def is_supported(self, platform=None):
return self.supported
def scan(self, roots, **_kwargs):
return self.scan_result
def empty(self, roots, **_kwargs):
self.empty_calls.append(list(roots))
if isinstance(self.empty_result, Exception):
raise self.empty_result
return self.empty_result if self.empty_result is not None else Result.Ok(
{"removed": 0, "bytes": 0, "failed": [], "skipped_dirs": 0}
)
def _install_trash(monkeypatch, fake: _FakeTrashService):
monkeypatch.setattr(m, "trash_service", fake, raising=False)
def _reset_trash_rate_limit():
from mjr_am_backend.routes.core.security_rate_limit import _reset_rate_limit_state_for_tests
_reset_rate_limit_state_for_tests()
@pytest.fixture
def trash_rate_limit_clean():
_reset_trash_rate_limit()
yield
_reset_trash_rate_limit()
@pytest.mark.asyncio
async def test_trash_status_success(monkeypatch, tmp_path: Path):
scan = ts_types.TrashScan(3, 128, ())
_install_trash(monkeypatch, _FakeTrashService(scan=scan))
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
monkeypatch.setattr(m, "_trash_roots", lambda: [tmp_path], raising=False)
app = _app()
req = make_mocked_request("GET", "/mjr/am/trash", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
assert payload.get("ok") is True
assert payload.get("data") == {"supported": True, "count": 3, "bytes": 128}
@pytest.mark.asyncio
async def test_trash_status_unsupported_platform(monkeypatch):
_install_trash(monkeypatch, _FakeTrashService(supported=False))
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
app = _app()
req = make_mocked_request("GET", "/mjr/am/trash", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
assert payload.get("ok") is True
assert payload.get("data") == {"supported": False, "count": 0, "bytes": 0}
@pytest.mark.asyncio
async def test_trash_status_requires_write_access(monkeypatch):
_install_trash(monkeypatch, _FakeTrashService())
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Err("FORBIDDEN", "no"))
app = _app()
req = make_mocked_request("GET", "/mjr/am/trash", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
assert payload.get("ok") is False
assert payload.get("code") == "FORBIDDEN"
@pytest.mark.asyncio
async def test_trash_status_scan_error_reported(monkeypatch):
def _boom(_roots, **_kwargs):
raise PermissionError("scan failed")
fake = _FakeTrashService()
fake.scan = _boom
_install_trash(monkeypatch, fake)
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
monkeypatch.setattr(m, "_trash_roots", lambda: [], raising=False)
app = _app()
req = make_mocked_request("GET", "/mjr/am/trash", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
assert payload.get("ok") is False
assert payload.get("code") == "TRASH_ERROR"
@pytest.mark.asyncio
async def test_trash_empty_success(monkeypatch, tmp_path: Path):
audit_calls: list[dict] = []
async def _audit(_services, *, request, operation, target, result, details=None):
audit_calls.append(
{
"operation": operation,
"target": target,
"details": dict(details or {}),
"ok": bool(result.ok),
}
)
monkeypatch.setattr(m, "audit_log_write", _audit)
fake = _FakeTrashService(
empty=Result.Ok({"removed": 2, "bytes": 99, "failed": [], "skipped_dirs": 1})
)
_install_trash(monkeypatch, fake)
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
async def _require_services():
return {"settings": object()}, None
monkeypatch.setattr(m, "_require_services", _require_services)
async def _prefs(_svc):
return {"safe_mode": False, "allow_delete": True}
monkeypatch.setattr(m, "_resolve_security_prefs", _prefs)
monkeypatch.delenv("MAJOOR_ALLOW_DELETE", raising=False)
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
monkeypatch.setattr(m, "_trash_roots", lambda: [tmp_path], raising=False)
_reset_trash_rate_limit()
try:
app = _app()
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
finally:
_reset_trash_rate_limit()
assert payload.get("ok") is True
assert payload.get("data") == {"removed": 2, "bytes": 99, "failed": [], "skipped_dirs": 1}
assert fake.empty_calls == [[tmp_path]]
assert len(audit_calls) == 1
assert audit_calls[0]["operation"] == "trash_empty"
assert audit_calls[0]["details"].get("removed") == 2
assert audit_calls[0]["details"].get("bytes") == 99
assert audit_calls[0]["details"].get("skipped_dirs") == 1
@pytest.mark.asyncio
async def test_trash_empty_reports_failed_entries(monkeypatch, tmp_path: Path):
failed = [{"name": "a.png", "error": "permission denied"}]
fake = _FakeTrashService(
empty=Result.Ok({"removed": 1, "bytes": 5, "failed": failed, "skipped_dirs": 0})
)
_install_trash(monkeypatch, fake)
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
async def _require_services():
return {"settings": object()}, None
monkeypatch.setattr(m, "_require_services", _require_services)
async def _prefs(_svc):
return {"safe_mode": False, "allow_delete": True}
monkeypatch.setattr(m, "_resolve_security_prefs", _prefs)
monkeypatch.delenv("MAJOOR_ALLOW_DELETE", raising=False)
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
monkeypatch.setattr(m, "_trash_roots", lambda: [tmp_path], raising=False)
_reset_trash_rate_limit()
try:
app = _app()
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
finally:
_reset_trash_rate_limit()
assert payload.get("ok") is True
assert payload.get("data", {}).get("failed") == failed
@pytest.mark.asyncio
async def test_trash_empty_unsupported_platform(monkeypatch):
fake = _FakeTrashService(supported=False)
_install_trash(monkeypatch, fake)
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
_reset_trash_rate_limit()
try:
app = _app()
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
finally:
_reset_trash_rate_limit()
assert payload.get("ok") is False
assert payload.get("code") == "UNSUPPORTED"
assert fake.empty_calls == []
@pytest.mark.asyncio
async def test_trash_empty_blocked_when_delete_disabled(monkeypatch):
"""Acceptance: MAJOOR_ALLOW_DELETE unset → POST refused, nothing removed."""
fake = _FakeTrashService()
_install_trash(monkeypatch, fake)
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
async def _require_services():
return {"settings": object()}, None
monkeypatch.setattr(m, "_require_services", _require_services)
async def _prefs(_svc):
return {"safe_mode": False, "allow_delete": True}
monkeypatch.setattr(m, "_resolve_security_prefs", _prefs)
monkeypatch.delenv("MAJOOR_ALLOW_DELETE", raising=False)
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
monkeypatch.setattr(
m,
"_require_operation_enabled",
lambda *_args, **_kwargs: Result.Err("FORBIDDEN", "Delete is disabled by default."),
)
_reset_trash_rate_limit()
try:
app = _app()
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
finally:
_reset_trash_rate_limit()
assert payload.get("ok") is False
assert payload.get("code") == "FORBIDDEN"
assert fake.empty_calls == []
@pytest.mark.asyncio
async def test_trash_empty_csrf_required(monkeypatch):
_install_trash(monkeypatch, _FakeTrashService())
monkeypatch.setattr(m, "_csrf_error", lambda _request: "missing csrf token")
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
_reset_trash_rate_limit()
try:
app = _app()
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
finally:
_reset_trash_rate_limit()
assert payload.get("ok") is False
assert payload.get("code") == "CSRF"
@pytest.mark.asyncio
async def test_trash_empty_rate_limited(monkeypatch):
fake = _FakeTrashService()
_install_trash(monkeypatch, fake)
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
async def _require_services():
return {"settings": object()}, None
monkeypatch.setattr(m, "_require_services", _require_services)
async def _prefs(_svc):
return {"safe_mode": False, "allow_delete": True}
monkeypatch.setattr(m, "_resolve_security_prefs", _prefs)
monkeypatch.delenv("MAJOOR_ALLOW_DELETE", raising=False)
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
monkeypatch.setattr(m, "_trash_roots", lambda: [], raising=False)
_reset_trash_rate_limit()
try:
app = _app()
codes = []
for _ in range(4):
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
codes.append(json.loads(resp.text).get("code"))
finally:
_reset_trash_rate_limit()
assert codes[:3] == ["OK", "OK", "OK"]
assert codes[3] == "RATE_LIMITED"
@pytest.mark.asyncio
async def test_trash_empty_real_gate_refuses_when_delete_disabled(monkeypatch, tmp_path: Path):
"""Wire the REAL operation gate: trash_empty refused while delete is off."""
monkeypatch.delenv("MAJOOR_ALLOW_DELETE", raising=False)
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
fake = _FakeTrashService()
_install_trash(monkeypatch, fake)
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
async def _require_services():
return {"settings": object()}, None
monkeypatch.setattr(m, "_require_services", _require_services)
async def _prefs(_svc):
return {"safe_mode": False, "allow_delete": False}
monkeypatch.setattr(m, "_resolve_security_prefs", _prefs)
monkeypatch.setattr(m, "_trash_roots", lambda: [tmp_path], raising=False)
_reset_trash_rate_limit()
try:
app = _app()
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
finally:
_reset_trash_rate_limit()
assert payload.get("ok") is False
assert payload.get("code") == "FORBIDDEN"
assert fake.empty_calls == []
@pytest.mark.asyncio
async def test_trash_empty_real_gate_allows_when_delete_enabled(monkeypatch, tmp_path: Path):
"""Wire the REAL operation gate: allow_delete permits trash_empty in safe mode."""
monkeypatch.delenv("MAJOOR_ALLOW_DELETE", raising=False)
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
fake = _FakeTrashService(
empty=Result.Ok({"removed": 1, "bytes": 2, "failed": [], "skipped_dirs": 0})
)
_install_trash(monkeypatch, fake)
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
async def _require_services():
return {"settings": object()}, None
monkeypatch.setattr(m, "_require_services", _require_services)
async def _prefs(_svc):
return {"safe_mode": True, "allow_delete": True}
monkeypatch.setattr(m, "_resolve_security_prefs", _prefs)
monkeypatch.setattr(m, "_trash_roots", lambda: [tmp_path], raising=False)
_reset_trash_rate_limit()
try:
app = _app()
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
finally:
_reset_trash_rate_limit()
assert payload.get("ok") is True
assert payload.get("data") == {"removed": 1, "bytes": 2, "failed": [], "skipped_dirs": 0}
assert fake.empty_calls == [[tmp_path]]
@pytest.mark.asyncio
async def test_trash_empty_real_gate_allows_via_env(monkeypatch, tmp_path: Path):
"""MAJOOR_ALLOW_DELETE=1 unlocks trash_empty like deletion."""
monkeypatch.setenv("MAJOOR_ALLOW_DELETE", "1")
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
fake = _FakeTrashService()
_install_trash(monkeypatch, fake)
monkeypatch.setattr(m, "_csrf_error", lambda _request: None)
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
async def _require_services():
return {"settings": object()}, None
monkeypatch.setattr(m, "_require_services", _require_services)
async def _prefs(_svc):
return {"safe_mode": True}
monkeypatch.setattr(m, "_resolve_security_prefs", _prefs)
monkeypatch.setattr(m, "_trash_roots", lambda: [tmp_path], raising=False)
_reset_trash_rate_limit()
try:
app = _app()
req = make_mocked_request("POST", "/mjr/am/trash/empty", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
finally:
_reset_trash_rate_limit()
assert payload.get("ok") is True
assert fake.empty_calls == [[tmp_path]]
@pytest.mark.asyncio
async def test_trash_status_rate_limited(monkeypatch):
"""GET /mjr/am/trash is rate-limited to 20 requests per 60 seconds."""
_install_trash(monkeypatch, _FakeTrashService())
monkeypatch.setattr(m, "_require_write_access", lambda _request: Result.Ok({}))
_reset_trash_rate_limit()
try:
app = _app()
codes = []
for _ in range(21):
req = make_mocked_request("GET", "/mjr/am/trash", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
codes.append(json.loads(resp.text).get("code"))
finally:
_reset_trash_rate_limit()
assert codes[:20] == ["OK"] * 20
assert codes[20] == "RATE_LIMITED"
+994
View File
@@ -0,0 +1,994 @@
# mypy: disable-error-code="index,union-attr"
"""Unit tests for the freedesktop trash service (synthetic trees only)."""
from __future__ import annotations
import os
import stat
from pathlib import Path
import pytest
from mjr_am_backend.features.assets import trash_service as ts
UID = os.getuid()
FOREIGN_UID = UID + 1
@pytest.fixture(autouse=True)
def isolate_trash_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Keep the service away from the real home/XDG trash of the test host."""
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "no-xdg"))
@pytest.fixture(autouse=True)
def fake_mount(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Treat tmp_path itself as a mount point; everything else uses the real check."""
real_ismount = os.path.ismount
def _fake_ismount(path):
try:
if Path(str(path)) == tmp_path:
return True
except Exception:
pass
return real_ismount(path)
monkeypatch.setattr(os.path, "ismount", _fake_ismount)
def _make_admin_trash(topdir: Path, uid: int = UID) -> Path:
td = topdir / f".Trash-{uid}"
(td / "info").mkdir(parents=True)
(td / "files").mkdir(parents=True)
return td
def _write_trashinfo(td: Path, name: str, path_value: str) -> Path:
info = td / "info" / f"{name}.trashinfo"
info.write_text(
f"[Trash Info]\nPath={path_value}\nDeletionDate=2026-01-01T00:00:00\n",
encoding="utf-8",
)
return info
class TestIsSupported:
def test_freedesktop_platforms_supported(self):
assert ts.is_supported("linux") is True
assert ts.is_supported("linux2") is True
assert ts.is_supported("freebsd14") is True
assert ts.is_supported("openbsd7") is True
assert ts.is_supported("netbsd10") is True
def test_windows_and_macos_unsupported(self):
assert ts.is_supported("win32") is False
assert ts.is_supported("darwin") is False
def test_default_uses_sys_platform(self):
import sys
assert ts.is_supported() is ts.is_supported(sys.platform)
class TestTrashDirs:
def test_home_trash_included_with_xdg_topdir(self, tmp_path: Path):
xdg = tmp_path / "xdg"
(xdg / "Trash" / "info").mkdir(parents=True)
(xdg / "Trash" / "files").mkdir(parents=True)
dirs = ts.trash_dirs([], uid=UID, xdg_data_home=xdg)
assert len(dirs) == 1
assert dirs[0].path == xdg / "Trash"
assert dirs[0].topdir == xdg
def test_home_trash_default_derived_from_home(self, tmp_path: Path, monkeypatch):
home = tmp_path / "home"
monkeypatch.delenv("XDG_DATA_HOME", raising=False)
dirs = ts.trash_dirs([], uid=UID, home=home)
assert dirs == []
(home / ".local" / "share" / "Trash" / "info").mkdir(parents=True)
dirs = ts.trash_dirs([], uid=UID, home=home)
assert [d.path for d in dirs] == [home / ".local" / "share" / "Trash"]
assert dirs[0].topdir == home / ".local" / "share"
def test_home_trash_env_var_used(self, tmp_path: Path, monkeypatch):
xdg = tmp_path / "envxdg"
monkeypatch.setenv("XDG_DATA_HOME", str(xdg))
home = tmp_path / "home"
(xdg / "Trash" / "info").mkdir(parents=True)
dirs = ts.trash_dirs([], uid=UID, home=home)
assert [d.path for d in dirs] == [xdg / "Trash"]
def test_home_trash_symlink_skipped(self, tmp_path: Path):
xdg = tmp_path / "xdg"
xdg.mkdir()
target = tmp_path / "real-trash"
target.mkdir()
os.symlink(target, xdg / "Trash")
dirs = ts.trash_dirs([], uid=UID, xdg_data_home=xdg)
assert dirs == []
def test_home_trash_foreign_owner_skipped(self, tmp_path: Path):
xdg = tmp_path / "xdg"
(xdg / "Trash" / "info").mkdir(parents=True)
dirs = ts.trash_dirs([], uid=FOREIGN_UID, xdg_data_home=xdg)
assert dirs == []
def test_home_trash_group_writable_skipped(self, tmp_path: Path):
xdg = tmp_path / "xdg"
trash = xdg / "Trash"
(trash / "info").mkdir(parents=True)
trash.chmod(0o770)
try:
dirs = ts.trash_dirs([], uid=UID, xdg_data_home=xdg)
finally:
trash.chmod(0o755)
assert dirs == []
def test_admin_trash_uid_dir_for_root_mount(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
dirs = ts.trash_dirs([root], uid=UID)
assert [d.path for d in dirs] == [td]
assert dirs[0].topdir == tmp_path
def test_admin_trash_foreign_owner_skipped(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
assert td.stat().st_uid == UID
dirs = ts.trash_dirs([root], uid=FOREIGN_UID)
assert dirs == []
def test_admin_trash_world_writable_skipped(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
td.chmod(0o777)
try:
dirs = ts.trash_dirs([root], uid=UID)
finally:
td.chmod(0o755)
assert dirs == []
def test_group_writable_files_subdir_skips_dir(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files").chmod(0o777)
try:
dirs = ts.trash_dirs([root], uid=UID)
finally:
(td / "files").chmod(0o755)
assert dirs == []
def test_foreign_owned_info_subdir_skips_dir(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
assert (td / "info").stat().st_uid == UID
dirs = ts.trash_dirs([root], uid=FOREIGN_UID)
assert dirs == []
def test_missing_files_subdir_tolerated(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = tmp_path / f".Trash-{UID}"
(td / "info").mkdir(parents=True)
dirs = ts.trash_dirs([root], uid=UID)
assert [d.path for d in dirs] == [td]
def test_sticky_trash_dir_included(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
sticky = tmp_path / ".Trash"
sticky.mkdir()
sticky.chmod(sticky.stat().st_mode | stat.S_ISVTX)
(sticky / str(UID) / "info").mkdir(parents=True)
(sticky / str(UID) / "files").mkdir(parents=True)
_make_admin_trash(tmp_path)
dirs = ts.trash_dirs([root], uid=UID)
assert {d.path for d in dirs} == {tmp_path / f".Trash-{UID}", sticky / str(UID)}
sticky_entry = next(d for d in dirs if d.path == sticky / str(UID))
assert sticky_entry.topdir == tmp_path
def test_sticky_dir_foreign_uid_child_skipped(self, tmp_path: Path):
"""Sticky .Trash is fine for anyone, but .Trash/<uid> must be ours."""
root = tmp_path / "out"
root.mkdir()
sticky = tmp_path / ".Trash"
sticky.mkdir()
sticky.chmod(sticky.stat().st_mode | stat.S_ISVTX)
child = sticky / str(FOREIGN_UID)
(child / "info").mkdir(parents=True)
(child / "files").mkdir(parents=True)
dirs = ts.trash_dirs([root], uid=FOREIGN_UID)
assert dirs == []
def test_non_sticky_trash_dir_skipped(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
plain = tmp_path / ".Trash"
(plain / str(UID) / "info").mkdir(parents=True)
_make_admin_trash(tmp_path)
dirs = ts.trash_dirs([root], uid=UID)
assert {d.path for d in dirs} == {tmp_path / f".Trash-{UID}"}
def test_symlinked_dot_trash_skipped(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
other = tmp_path / "evil"
other.mkdir()
(other / str(UID) / "info").mkdir(parents=True)
os.symlink(other, tmp_path / ".Trash", target_is_directory=True)
_make_admin_trash(tmp_path)
dirs = ts.trash_dirs([root], uid=UID)
assert {d.path for d in dirs} == {tmp_path / f".Trash-{UID}"}
def test_symlinked_trash_dir_skipped(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
target = tmp_path / "realtrash"
target.mkdir()
os.symlink(target, tmp_path / f".Trash-{UID}", target_is_directory=True)
dirs = ts.trash_dirs([root], uid=UID)
assert dirs == []
def test_symlinked_files_or_info_skips_dir(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = tmp_path / f".Trash-{UID}"
td.mkdir()
(td / "files").mkdir()
evil = tmp_path / "evilinfo"
evil.mkdir()
os.symlink(evil, td / "info")
dirs = ts.trash_dirs([root], uid=UID)
assert dirs == []
def test_dedupe_same_mount_scanned_once(self, tmp_path: Path):
root_a = tmp_path / "out"
root_a.mkdir()
root_b = tmp_path / "custom"
root_b.mkdir()
td = _make_admin_trash(tmp_path)
dirs = ts.trash_dirs([root_a, root_b], uid=UID)
assert [d.path for d in dirs] == [td]
def test_root_alias_deduped(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
alias = tmp_path / "link-out"
os.symlink(root, alias, target_is_directory=True)
td = _make_admin_trash(tmp_path)
dirs = ts.trash_dirs([root, alias], uid=UID)
assert [d.path for d in dirs] == [td]
def test_invalid_root_skipped(self, tmp_path: Path):
dirs = ts.trash_dirs(["/bad\x00root"], uid=UID)
assert dirs == []
def test_missing_trash_dirs_not_reported(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
dirs = ts.trash_dirs([root], uid=UID)
assert dirs == []
def test_uid_defaults_to_current_user(self, tmp_path: Path):
xdg = tmp_path / "xdg"
(xdg / "Trash" / "info").mkdir(parents=True)
dirs = ts.trash_dirs([], xdg_data_home=xdg)
assert [d.path for d in dirs] == [xdg / "Trash"]
def test_mount_point_fallback_when_no_mount_found(self, tmp_path: Path, monkeypatch):
root = tmp_path / "deep" / "out"
root.mkdir(parents=True)
monkeypatch.setattr(os.path, "ismount", lambda _p: False)
dirs = ts.trash_dirs([root], uid=UID)
assert dirs == []
def test_candidate_dedupe_symlinked_alias_trash_dir(self, tmp_path: Path, monkeypatch):
m1 = tmp_path / "m1"
m2 = tmp_path / "m2"
(m1 / "out").mkdir(parents=True)
(m2 / "out").mkdir(parents=True)
real_trash = m1 / f".Trash-{UID}"
(real_trash / "info").mkdir(parents=True)
(real_trash / "files").mkdir(parents=True)
os.symlink(real_trash, m2 / f".Trash-{UID}", target_is_directory=True)
real_ismount = os.path.ismount
def _fake_ismount(path):
if Path(str(path)) in (m1, m2):
return True
return real_ismount(path)
monkeypatch.setattr(os.path, "ismount", _fake_ismount)
dirs = ts.trash_dirs([m1 / "out", m2 / "out"], uid=UID)
assert [d.path for d in dirs] == [real_trash]
class TestScan:
def test_relative_path_inside_root(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"12345")
_write_trashinfo(td, "a.png", "out/a.png")
scan = ts.scan([root], uid=UID)
assert scan.count == 1
assert scan.bytes == 5
entry = scan.entries[0]
assert entry.trash_dir == td
assert entry.payload_name == "a.png"
assert entry.info_name == "a.png.trashinfo"
def test_absolute_encoded_path_home_trash(self, tmp_path: Path):
root = tmp_path / "roots" / "out"
root.mkdir(parents=True)
xdg = tmp_path / "xdg"
td = xdg / "Trash"
(td / "info").mkdir(parents=True)
(td / "files").mkdir(parents=True)
(td / "files" / "my cat 2.png").write_bytes(b"x" * 7)
_write_trashinfo(td, "my cat 2.png", f"{root}/my%20cat%202.png")
scan = ts.scan([root], uid=UID, xdg_data_home=xdg)
assert scan.count == 1
assert scan.bytes == 7
def test_outside_root_ignored(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "x.png").write_bytes(b"zz")
_write_trashinfo(td, "x.png", "/etc/passwd")
_write_trashinfo(td, "y.png", "other/app/x.png")
scan = ts.scan([root], uid=UID)
assert scan.count == 0
def test_traversal_escape_ignored(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "t.png").write_bytes(b"z")
_write_trashinfo(td, "t.png", "out/../../etc/x")
scan = ts.scan([root], uid=UID)
assert scan.count == 0
def test_string_prefix_not_containment(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
sibling = tmp_path / "outwardly"
sibling.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "p.png").write_bytes(b"z")
_write_trashinfo(td, "p.png", str(sibling / "p.png"))
scan = ts.scan([root], uid=UID)
assert scan.count == 0
def test_nul_in_path_ignored_valid_entry_still_selected(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"abc")
(td / "files" / "n.png").write_bytes(b"n")
_write_trashinfo(td, "a.png", "out/a.png")
_write_trashinfo(td, "n.png", "out/a%00.png")
scan = ts.scan([root], uid=UID)
assert scan.count == 1
assert scan.entries[0].payload_name == "a.png"
@pytest.mark.parametrize(
"content",
[
"no header\nPath=/out/a.png\n",
"[Trash Info]\nDeletionDate=2026-01-01T00:00:00\n",
"[Trash Info]\nPath=\n",
"\x00\x01\x02binary",
],
ids=["no-header", "no-path", "empty-path", "binary"],
)
def test_malformed_info_ignored(self, tmp_path: Path, content: str):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "m.png").write_bytes(b"z")
(td / "info" / "m.png.trashinfo").write_bytes(content.encode("utf-8", errors="ignore"))
scan = ts.scan([root], uid=UID)
assert scan.count == 0
def test_info_symlink_ignored(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
real_info = tmp_path / "elsewhere.trashinfo"
real_info.write_text(f"[Trash Info]\nPath={root}/a.png\n")
os.symlink(real_info, td / "info" / "a.png.trashinfo")
(td / "files" / "a.png").write_bytes(b"z")
scan = ts.scan([root], uid=UID)
assert scan.count == 0
def test_junk_file_in_info_dir_ignored(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "info" / "notes.txt").write_text("not a trashinfo")
scan = ts.scan([root], uid=UID)
assert scan.count == 0
def test_info_lstat_error_ignored(self, tmp_path: Path, monkeypatch):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
info = td / "info" / "a.png.trashinfo"
info.write_text("[Trash Info]\nPath=out/a.png\n")
real_lstat = os.lstat
def _guarded_lstat(path, *args, **kwargs):
if str(path) == str(info):
raise PermissionError("denied")
return real_lstat(path, *args, **kwargs)
monkeypatch.setattr(os, "lstat", _guarded_lstat)
scan = ts.scan([root], uid=UID)
assert scan.count == 0
def test_hostile_info_entry_never_fails_scan(self, tmp_path: Path):
"""A DirEntry whose metadata raises is skipped, not fatal."""
class _Hostile:
@property
def name(self):
raise ValueError("hostile name")
path = "irrelevant"
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"abc")
_write_trashinfo(td, "a.png", "out/a.png")
real_scandir = os.scandir
def _scandir_with_hostile(path, *args, **kwargs):
listing = list(real_scandir(path, *args, **kwargs))
if Path(str(path)) == td / "info":
listing.append(_Hostile())
return listing
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(os, "scandir", _scandir_with_hostile)
try:
scan = ts.scan([root], uid=UID)
finally:
monkeypatch.undo()
assert scan.count == 1
def test_directory_payload_size_zero(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
payload = td / "files" / "d"
(payload / "sub").mkdir(parents=True)
(payload / "one.png").write_bytes(b"a" * 10)
(payload / "sub" / "two.png").write_bytes(b"b" * 20)
_write_trashinfo(td, "d", "out/d")
scan = ts.scan([root], uid=UID)
assert scan.count == 1
assert scan.bytes == 0
def test_symlink_payload_size_from_lstat(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
target = tmp_path / "keep.png"
target.write_bytes(b"x" * 100)
os.symlink(target, td / "files" / "link.png")
_write_trashinfo(td, "link.png", "out/link.png")
scan = ts.scan([root], uid=UID)
assert scan.count == 1
# Size must come from the link itself (lstat), never from the target.
assert scan.bytes == os.lstat(td / "files" / "link.png").st_size
assert scan.bytes != 100
def test_missing_payload_size_zero(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
_write_trashinfo(td, "gone.png", "out/gone.png")
scan = ts.scan([root], uid=UID)
assert scan.count == 1
assert scan.bytes == 0
def test_dedupe_two_roots_same_mount_counted_once(self, tmp_path: Path):
out_root = tmp_path / "out"
custom_root = tmp_path / "custom"
out_root.mkdir()
custom_root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"123")
_write_trashinfo(td, "a.png", "out/a.png")
scan = ts.scan([out_root, custom_root], uid=UID)
assert scan.count == 1
def test_same_root_twice_deduped(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"123")
_write_trashinfo(td, "a.png", "out/a.png")
scan = ts.scan([root, root], uid=UID)
assert scan.count == 1
def test_missing_info_dir_tolerated(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = tmp_path / f".Trash-{UID}"
(td / "files").mkdir(parents=True)
scan = ts.scan([root], uid=UID)
assert scan.count == 0
def test_info_scandir_oserror_tolerated(self, tmp_path: Path, monkeypatch):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
_write_trashinfo(td, "a.png", "out/a.png")
real_scandir = os.scandir
def _raise_on_info(path, *args, **kwargs):
if Path(str(path)) == td / "info":
raise PermissionError("denied")
return real_scandir(path, *args, **kwargs)
monkeypatch.setattr(os, "scandir", _raise_on_info)
scan = ts.scan([root], uid=UID)
assert scan.count == 0
def test_read_error_info_ignored(self, tmp_path: Path):
"""A directory as the info target: reading fails, treated as malformed."""
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "info" / "weird.trashinfo").mkdir()
assert ts._parse_trashinfo(td / "info" / "weird.trashinfo") is None
def test_info_swapped_for_symlink_malformed(self, tmp_path: Path):
"""A symlink swapped in after the lstat fails O_NOFOLLOW → malformed."""
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
target = tmp_path / "planted.trashinfo"
target.write_text("[Trash Info]\nPath=out/a.png\n")
os.symlink(target, td / "info" / "a.png.trashinfo")
assert ts._parse_trashinfo(td / "info" / "a.png.trashinfo") is None
@pytest.mark.parametrize(
"crafted",
[".trashinfo", "..trashinfo", "...trashinfo"],
ids=["empty-stem", "dot", "dot-dot"],
)
def test_crafted_info_names_rejected(self, tmp_path: Path, crafted: str):
"""Info basenames whose stem is empty/./.. must never target files/ or the trash dir."""
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "info" / crafted).write_text("[Trash Info]\nPath=out/a.png\n")
(td / "files" / "foreign.png").write_bytes(b"foreign")
scan = ts.scan([root], uid=UID)
assert scan.count == 0
result = ts.empty([root], uid=UID)
assert result.ok and result.data["removed"] == 0
assert (td / "files" / "foreign.png").read_bytes() == b"foreign"
assert (td / "info" / crafted).exists()
assert (td / "info").is_dir()
assert (td / "files").is_dir()
def test_symlinked_root_prefix_path_selected(self, tmp_path: Path):
"""Path= recorded through a symlinked root prefix still matches the root."""
real_root = tmp_path / "data" / "out"
real_root.mkdir(parents=True)
alias = tmp_path / "link"
os.symlink(tmp_path / "data", alias, target_is_directory=True)
td = _make_admin_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"abc")
_write_trashinfo(td, "a.png", str(alias / "out" / "a.png"))
scan = ts.scan([alias / "out"], uid=UID)
assert scan.count == 1
result = ts.empty([alias / "out"], uid=UID)
assert result.ok and result.data["removed"] == 1
assert not (td / "files" / "a.png").exists()
def test_path_realpath_outside_roots_ignored(self, tmp_path: Path):
"""A Path whose lexical and realpath forms both sit outside roots is ignored."""
root = tmp_path / "out"
root.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
decoy = tmp_path / "decoy"
os.symlink(outside, decoy, target_is_directory=True)
td = _make_admin_trash(tmp_path)
(td / "files" / "x.png").write_bytes(b"x")
_write_trashinfo(td, "x.png", str(decoy / "x.png"))
scan = ts.scan([root], uid=UID)
assert scan.count == 0
def test_oversized_trashinfo_ignored(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "big.png").write_bytes(b"z")
padding = "x" * (70 * 1024)
(td / "info" / "big.png.trashinfo").write_text(
f"[Trash Info]\nPath=out/big.png\nJunk={padding}\n", encoding="utf-8"
)
scan = ts.scan([root], uid=UID)
assert scan.count == 0
class TestEntryFromInfoSubGuards:
"""Defence-in-depth guards real scandir output can never reach."""
def _trash_dir(self, tmp_path: Path) -> ts.TrashDir:
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "ok.png").write_bytes(b"z")
_write_trashinfo(td, "ok.png", "out/ok.png")
dirs = ts.trash_dirs([root], uid=UID)
assert len(dirs) == 1
return dirs[0]
@pytest.mark.parametrize(
"name",
[
"sub/a.png.trashinfo",
"a\x00b.png.trashinfo",
"a\\b.png.trashinfo",
],
ids=["sep", "nul", "altsep"],
)
def test_separator_or_nul_stem_rejected(self, tmp_path: Path, name: str):
trash_dir = self._trash_dir(tmp_path)
fake = type("FakeDirEntry", (), {"name": name, "path": str(trash_dir.files_dir / "x")})()
root = tmp_path / "out"
entry = ts._entry_from_info(fake, trash_dir, [root], [root])
assert entry is None
def test_valid_fake_entry_selected(self, tmp_path: Path):
trash_dir = self._trash_dir(tmp_path)
(trash_dir.files_dir / "fake.png").write_bytes(b"1234")
info_file = trash_dir.info_dir / "fake.png.trashinfo"
info_file.write_text("[Trash Info]\nPath=out/fake.png\n")
fake = type("FakeDirEntry", (), {"name": "fake.png.trashinfo", "path": str(info_file)})()
root = tmp_path / "out"
entry = ts._entry_from_info(fake, trash_dir, [root], [root])
assert entry is not None
assert entry.payload_name == "fake.png"
assert entry.size == 4
class TestEmpty:
def test_removes_only_majoor_entries(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
for i in range(3):
(td / "files" / f"m{i}.png").write_bytes(f"content{i}".encode())
_write_trashinfo(td, f"m{i}.png", f"out/m{i}.png")
(td / "files" / "foreign.png").write_bytes(b"foreign-bytes")
foreign_info = _write_trashinfo(td, "foreign.png", "/elsewhere/foreign.png")
(td / "files" / "orphan.png").write_bytes(b"orphan-bytes")
scan = ts.scan([root], uid=UID)
assert scan.count == 3
result = ts.empty([root], uid=UID)
assert result.ok
assert result.data["removed"] == 3
assert result.data["bytes"] == len("content0") + len("content1") + len("content2")
assert result.data["failed"] == []
assert result.data["skipped_dirs"] == 0
for i in range(3):
assert not (td / "files" / f"m{i}.png").exists()
assert not (td / "info" / f"m{i}.png.trashinfo").exists()
assert (td / "files" / "foreign.png").read_bytes() == b"foreign-bytes"
assert foreign_info.exists()
assert (td / "files" / "orphan.png").read_bytes() == b"orphan-bytes"
def test_symlink_payload_unlinked_target_intact(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
target = tmp_path / "outside" / "keep.png"
target.parent.mkdir()
target.write_bytes(b"precious")
os.symlink(target, td / "files" / "a.png")
_write_trashinfo(td, "a.png", "out/a.png")
result = ts.empty([root], uid=UID)
assert result.ok and result.data["removed"] == 1
assert not (td / "files" / "a.png").is_symlink()
assert target.read_bytes() == b"precious"
def test_directory_payload_skipped_never_removed(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
payload = td / "files" / "d"
(payload / "inner.png").mkdir(parents=True)
(payload / "inner.png" / "x.png").write_bytes(b"xxx")
_write_trashinfo(td, "d", "out/d")
result = ts.empty([root], uid=UID)
assert result.ok
assert result.data["removed"] == 0
assert result.data["skipped_dirs"] == 1
assert (payload / "inner.png" / "x.png").read_bytes() == b"xxx"
assert (td / "info" / "d.trashinfo").exists()
def test_fifo_payload_skipped(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
os.mkfifo(td / "files" / "pipe")
_write_trashinfo(td, "pipe", "out/pipe")
try:
result = ts.empty([root], uid=UID)
assert result.ok
assert result.data["removed"] == 0
assert result.data["skipped_dirs"] == 1
assert (td / "files" / "pipe").exists()
finally:
(td / "files" / "pipe").unlink()
def test_vanished_payload_still_removes_info(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
info = _write_trashinfo(td, "a.png", "out/a.png")
result = ts.empty([root], uid=UID)
assert result.ok
assert result.data["removed"] == 1
assert result.data == {"removed": 1, "bytes": 0, "failed": [], "skipped_dirs": 0}
assert not info.exists()
def test_payload_unlink_file_not_found_tolerated(self, tmp_path: Path, monkeypatch):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
payload = td / "files" / "a.png"
payload.write_bytes(b"x")
info = _write_trashinfo(td, "a.png", "out/a.png")
real_unlink = os.unlink
def _vanishing_unlink(path, *args, **kwargs):
if str(path) == "a.png":
raise FileNotFoundError("vanished")
return real_unlink(path, *args, **kwargs)
monkeypatch.setattr(os, "unlink", _vanishing_unlink)
try:
result = ts.empty([root], uid=UID)
finally:
monkeypatch.undo()
assert result.ok and result.data["removed"] == 1
assert not info.exists()
def test_info_unlink_file_not_found_tolerated(self, tmp_path: Path, monkeypatch):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
payload = td / "files" / "a.png"
payload.write_bytes(b"x")
_write_trashinfo(td, "a.png", "out/a.png")
real_unlink = os.unlink
def _vanishing_unlink(path, *args, **kwargs):
if str(path) == "a.png.trashinfo":
raise FileNotFoundError("vanished")
return real_unlink(path, *args, **kwargs)
monkeypatch.setattr(os, "unlink", _vanishing_unlink)
try:
result = ts.empty([root], uid=UID)
finally:
monkeypatch.undo()
assert result.ok and result.data["removed"] == 1
assert not payload.exists()
def test_payload_unlink_permission_error_recorded(self, tmp_path: Path, monkeypatch):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
blocked = td / "files" / "blocked.png"
blocked.write_bytes(b"secret")
_write_trashinfo(td, "blocked.png", "out/blocked.png")
(td / "files" / "fine.png").write_bytes(b"f")
_write_trashinfo(td, "fine.png", "out/fine.png")
real_unlink = os.unlink
def _guarded_unlink(path, *args, **kwargs):
if str(path) == "blocked.png":
raise PermissionError("permission denied")
return real_unlink(path, *args, **kwargs)
monkeypatch.setattr(os, "unlink", _guarded_unlink)
try:
result = ts.empty([root], uid=UID)
finally:
monkeypatch.undo()
assert result.ok
assert result.data["removed"] == 1
assert len(result.data["failed"]) == 1
failure = result.data["failed"][0]
assert failure["name"] == "blocked.png"
assert "permission denied" in failure["error"].lower()
assert blocked.exists()
assert (td / "info" / "blocked.png.trashinfo").exists()
assert not (td / "info" / "fine.png.trashinfo").exists()
def test_info_unlink_error_recorded(self, tmp_path: Path, monkeypatch):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"x")
info = _write_trashinfo(td, "a.png", "out/a.png")
real_unlink = os.unlink
def _guarded_unlink(path, *args, **kwargs):
if str(path) == "a.png.trashinfo":
raise PermissionError("info denied")
return real_unlink(path, *args, **kwargs)
monkeypatch.setattr(os, "unlink", _guarded_unlink)
try:
result = ts.empty([root], uid=UID)
finally:
monkeypatch.undo()
assert result.ok
assert result.data["removed"] == 0
assert result.data["failed"][0]["name"] == "a.png"
assert info.exists()
def test_payload_lstat_error_recorded(self, tmp_path: Path, monkeypatch):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
payload = td / "files" / "a.png"
payload.write_bytes(b"x")
_write_trashinfo(td, "a.png", "out/a.png")
real_lstat = os.lstat
def _guarded_lstat(path, *args, **kwargs):
if str(path) == "a.png" and kwargs.get("dir_fd") is not None:
raise PermissionError("stat denied")
return real_lstat(path, *args, **kwargs)
monkeypatch.setattr(os, "lstat", _guarded_lstat)
try:
result = ts.empty([root], uid=UID)
finally:
monkeypatch.undo()
assert result.ok
assert result.data["removed"] == 0
assert result.data["failed"][0]["name"] == "a.png"
assert (td / "info" / "a.png.trashinfo").exists()
def test_missing_trash_dir_open_failed(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
entry = ts.TrashEntry(
trash_dir=tmp_path / "no-such-trash",
payload_name="a.png",
info_name="a.png.trashinfo",
size=1,
)
outcome, error = ts._remove_entry(entry, uid=UID)
assert outcome == "failed"
assert error
def test_files_swapped_for_symlink_after_scan(self, tmp_path: Path):
"""TOCTOU: files/ replaced by a symlink after scan → failed, outside untouched."""
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"abc")
_write_trashinfo(td, "a.png", "out/a.png")
scan = ts.scan([root], uid=UID)
assert scan.count == 1
outside = tmp_path / "outside"
outside.mkdir()
(outside / "loot.png").write_bytes(b"loot")
(td / "files" / "a.png").unlink() # empty files/ so rmdir works
(td / "files").rmdir()
os.symlink(outside, td / "files", target_is_directory=True)
outcome, error = ts._remove_entry(scan.entries[0], uid=UID)
assert outcome == "failed"
assert error
assert (outside / "loot.png").read_bytes() == b"loot"
def test_info_swapped_for_symlink_after_scan(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"abc")
_write_trashinfo(td, "a.png", "out/a.png")
scan = ts.scan([root], uid=UID)
assert scan.count == 1
outside = tmp_path / "outside-info"
outside.mkdir()
(td / "info" / "a.png.trashinfo").unlink()
(td / "info").rmdir()
os.symlink(outside, td / "info", target_is_directory=True)
outcome, error = ts._remove_entry(scan.entries[0], uid=UID)
assert outcome == "failed"
assert error
assert (td / "files" / "a.png").exists()
def test_ownership_recheck_fails_after_mode_change(self, tmp_path: Path):
"""files/ chmod'd group-writable after scan → refused, payload kept."""
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"abc")
_write_trashinfo(td, "a.png", "out/a.png")
scan = ts.scan([root], uid=UID)
assert scan.count == 1
(td / "files").chmod(0o770)
try:
outcome, error = ts._remove_entry(scan.entries[0], uid=UID)
finally:
(td / "files").chmod(0o755)
assert outcome == "failed"
assert "ownership" in error.lower()
assert (td / "files" / "a.png").exists()
def test_fstat_error_fails_recheck(self, tmp_path: Path, monkeypatch):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"abc")
_write_trashinfo(td, "a.png", "out/a.png")
scan = ts.scan([root], uid=UID)
assert scan.count == 1
# Scope the failure to the service's own re-check: pytest's capture
# machinery also calls os.fstat before monkeypatch teardown.
import sys as _sys
real_fstat = os.fstat
def _raise_for_owned_check(fd, *args, **kwargs):
if _sys._getframe(1).f_code.co_name == "_fd_owned_safe":
raise PermissionError("fstat denied")
return real_fstat(fd, *args, **kwargs)
monkeypatch.setattr(os, "fstat", _raise_for_owned_check)
outcome, error = ts._remove_entry(scan.entries[0], uid=UID)
assert outcome == "failed"
assert "ownership" in error.lower()
def test_empty_trash_no_entries(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
_make_admin_trash(tmp_path)
result = ts.empty([root], uid=UID)
assert result.ok
assert result.data == {"removed": 0, "bytes": 0, "failed": [], "skipped_dirs": 0}
def test_empty_uid_defaults_to_current_user(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_admin_trash(tmp_path, uid=os.getuid())
(td / "files" / "a.png").write_bytes(b"ab")
_write_trashinfo(td, "a.png", "out/a.png")
result = ts.empty([root])
assert result.ok and result.data["removed"] == 1
@@ -349,3 +349,26 @@ def monkeypatch():
_mp = pytest.MonkeyPatch()
yield _mp
_mp.undo()
def test_require_operation_enabled_trash_empty_shares_delete_gate(monkeypatch) -> None:
"""trash_empty must ride the delete gate, not fall through to unknown-op."""
monkeypatch.delenv("MAJOOR_ALLOW_DELETE", raising=False)
monkeypatch.delenv("MAJOOR_SAFE_MODE", raising=False)
refused = sec._require_operation_enabled(
"trash_empty", prefs={"safe_mode": False, "allow_delete": False}
)
assert not refused.ok
assert refused.code == "FORBIDDEN"
# Recognised operation: allow_delete prefs permit it even in safe mode.
allowed = sec._require_operation_enabled(
"trash_empty", prefs={"safe_mode": True, "allow_delete": "true"}
)
assert allowed.ok
# Env fallback mirrors MAJOOR_ALLOW_DELETE like asset deletion.
monkeypatch.setenv("MAJOOR_ALLOW_DELETE", "1")
via_env = sec._require_operation_enabled("trash_empty", prefs={"safe_mode": True})
assert via_env.ok
+321
View File
@@ -0,0 +1,321 @@
# mypy: disable-error-code="index"
"""Security tests for emptying Majoor-owned freedesktop trash entries.
Every test builds a synthetic trash tree under pytest's tmp_path. The service
must never remove an entry whose original path is outside Majoor roots, an
orphan payload, or anything reached through a symlink.
"""
from __future__ import annotations
import os
import stat
from pathlib import Path
import pytest
from mjr_am_backend.features.assets import trash_service as ts
UID = os.getuid()
@pytest.fixture(autouse=True)
def isolate_trash_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "no-xdg"))
@pytest.fixture(autouse=True)
def fake_mount(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
real_ismount = os.path.ismount
def _fake_ismount(path):
try:
if Path(str(path)) == tmp_path:
return True
except Exception:
pass
return real_ismount(path)
monkeypatch.setattr(os.path, "ismount", _fake_ismount)
def _make_trash(topdir: Path) -> Path:
td = topdir / f".Trash-{UID}"
(td / "info").mkdir(parents=True)
(td / "files").mkdir(parents=True)
return td
def _info(td: Path, name: str, path_value: str) -> Path:
p = td / "info" / f"{name}.trashinfo"
p.write_text(f"[Trash Info]\nPath={path_value}\n", encoding="utf-8")
return p
class TestTrashEmptySecurity:
def test_only_majoor_entries_removed_foreign_byte_identical(self, tmp_path: Path):
"""Acceptance: 3 Majoor entries removed; 2 foreign entries untouched."""
root = tmp_path / "out"
root.mkdir()
td = _make_trash(tmp_path)
for i in range(3):
(td / "files" / f"m{i}.png").write_bytes(f"majoor{i}".encode())
_info(td, f"m{i}.png", f"out/m{i}.png")
(td / "files" / "outside.png").write_bytes(b"outside-bytes")
outside_info = _info(td, "outside.png", "/home/attacker/outside.png")
(td / "files" / "orphan.png").write_bytes(b"orphan-bytes")
result = ts.empty([root], uid=UID)
assert result.ok
assert result.data["removed"] == 3
assert (td / "files" / "outside.png").read_bytes() == b"outside-bytes"
assert outside_info.exists()
assert (td / "files" / "orphan.png").read_bytes() == b"orphan-bytes"
for i in range(3):
assert not (td / "files" / f"m{i}.png").exists()
assert not (td / "info" / f"m{i}.png.trashinfo").exists()
def test_etc_passwd_path_can_be_faked_but_never_matched(self, tmp_path: Path):
"""An attacker-controlled trashinfo claiming /etc/passwd stays ignored."""
root = tmp_path / "out"
root.mkdir()
td = _make_trash(tmp_path)
(td / "files" / "passwd").write_bytes(b"root:x:0:0")
info = _info(td, "passwd", "/etc/passwd")
result = ts.empty([root], uid=UID)
assert result.ok and result.data["removed"] == 0
assert (td / "files" / "passwd").exists()
assert info.exists()
def test_traversal_payload_name_is_not_a_path(self, tmp_path: Path):
"""Traversal in Path= normalises outside the root and is ignored."""
root = tmp_path / "out"
root.mkdir()
secret = tmp_path / "secret.txt"
secret.write_bytes(b"secret")
td = _make_trash(tmp_path)
(td / "files" / "t.png").write_bytes(b"z")
info = _info(td, "t.png", "out/../../secret.txt")
result = ts.empty([root], uid=UID)
assert result.ok and result.data["removed"] == 0
assert secret.read_bytes() == b"secret"
assert info.exists()
def test_symlink_payload_never_follows_target(self, tmp_path: Path):
"""Payload symlink to a file outside roots: link removed, target intact."""
root = tmp_path / "out"
root.mkdir()
td = _make_trash(tmp_path)
victim = tmp_path / "victim" / "private.png"
victim.parent.mkdir()
victim.write_bytes(b"private-data")
os.symlink(victim, td / "files" / "a.png")
_info(td, "a.png", "out/a.png")
result = ts.empty([root], uid=UID)
assert result.ok and result.data["removed"] == 1
assert victim.read_bytes() == b"private-data"
assert not (td / "files" / "a.png").exists()
def test_directory_payloads_are_skipped_not_removed(self, tmp_path: Path):
"""Directory payloads (even with symlinks inside) are never removed."""
root = tmp_path / "out"
root.mkdir()
td = _make_trash(tmp_path)
victim_dir = tmp_path / "victimdir"
victim_dir.mkdir()
(victim_dir / "keep.png").write_bytes(b"keep-me")
payload = td / "files" / "d"
payload.mkdir()
os.symlink(victim_dir, payload / "escape")
_info(td, "d", "out/d")
result = ts.empty([root], uid=UID)
assert result.ok
assert result.data["removed"] == 0
assert result.data["skipped_dirs"] == 1
assert (victim_dir / "keep.png").read_bytes() == b"keep-me"
assert (payload / "escape").exists()
def test_foreign_owned_trash_dir_never_used(self, tmp_path: Path):
"""A .Trash-<uid> planted by another user is ignored entirely."""
root = tmp_path / "out"
root.mkdir()
td = _make_trash(tmp_path)
(td / "files" / "planted.png").write_bytes(b"planted")
_info(td, "planted.png", "out/planted.png")
assert td.stat().st_uid == UID
# Scan as a different (foreign) uid: the dir belongs to UID, not to them.
result = ts.empty([root], uid=UID + 1)
assert result.ok and result.data["removed"] == 0
assert (td / "files" / "planted.png").exists()
assert (td / "info" / "planted.png.trashinfo").exists()
def test_group_writable_trash_dir_never_used(self, tmp_path: Path):
"""Group/world-writable trash dirs are refused (planted-state defence)."""
root = tmp_path / "out"
root.mkdir()
td = _make_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"a")
_info(td, "a.png", "out/a.png")
td.chmod(0o777)
try:
scan = ts.scan([root], uid=UID)
result = ts.empty([root], uid=UID)
finally:
td.chmod(0o755)
assert scan.count == 0
assert result.ok and result.data["removed"] == 0
assert (td / "files" / "a.png").exists()
def test_nul_in_path_with_valid_entry_alongside(self, tmp_path: Path):
"""A NUL-bearing Path= entry is ignored without failing the scan."""
root = tmp_path / "out"
root.mkdir()
td = _make_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"abc")
(td / "files" / "n.png").write_bytes(b"n")
_info(td, "a.png", "out/a.png")
_info(td, "n.png", "out/a%00.png")
result = ts.empty([root], uid=UID)
assert result.ok
assert result.data["removed"] == 1
assert not (td / "files" / "a.png").exists()
assert (td / "files" / "n.png").exists()
assert (td / "info" / "n.png.trashinfo").exists()
def test_files_dir_swapped_for_symlink_after_scan(self, tmp_path: Path):
"""TOCTOU: files/ swapped for a symlink between scan and removal."""
root = tmp_path / "out"
root.mkdir()
td = _make_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"abc")
_info(td, "a.png", "out/a.png")
scan = ts.scan([root], uid=UID)
assert scan.count == 1
outside = tmp_path / "outside"
outside.mkdir()
(outside / "loot.png").write_bytes(b"loot")
(td / "files" / "a.png").unlink()
(td / "files").rmdir()
os.symlink(outside, td / "files", target_is_directory=True)
# empty() re-scans (and would reject the swapped dir), so target the
# removal step directly to exercise the scan→removal race window.
outcome, error = ts._remove_entry(scan.entries[0], uid=UID)
assert outcome == "failed"
assert error
assert (outside / "loot.png").read_bytes() == b"loot"
assert (td / "files").is_symlink()
def test_symlinked_trash_dir_never_entered(self, tmp_path: Path):
"""A .Trash-<uid> symlink pointing at a sensitive dir is skipped."""
root = tmp_path / "out"
root.mkdir()
sensitive = tmp_path / "sensitive"
(sensitive / "info").mkdir(parents=True)
(sensitive / "files").mkdir(parents=True)
(sensitive / "files" / "a.png").write_bytes(b"a")
_info(sensitive, "a.png", "out/a.png")
os.symlink(sensitive, tmp_path / f".Trash-{UID}", target_is_directory=True)
result = ts.empty([root], uid=UID)
assert result.ok and result.data["removed"] == 0
assert (sensitive / "files" / "a.png").exists()
def test_non_sticky_dot_trash_skipped(self, tmp_path: Path):
"""Without the sticky bit, <topdir>/.Trash/<uid> must not be trusted."""
root = tmp_path / "out"
root.mkdir()
shared = tmp_path / ".Trash"
(shared / str(UID) / "info").mkdir(parents=True)
(shared / str(UID) / "files").mkdir(parents=True)
(shared / str(UID) / "files" / "a.png").write_bytes(b"a")
_info(shared / str(UID), "a.png", "out/a.png")
assert not (shared.stat().st_mode & stat.S_ISVTX)
result = ts.empty([root], uid=UID)
assert result.ok and result.data["removed"] == 0
assert (shared / str(UID) / "files" / "a.png").exists()
def test_symlinked_dot_trash_skipped(self, tmp_path: Path):
""".Trash as a symlink is not a valid sticky admin trash parent."""
root = tmp_path / "out"
root.mkdir()
evil = tmp_path / "evil"
(evil / str(UID) / "info").mkdir(parents=True)
(evil / str(UID) / "files").mkdir(parents=True)
(evil / str(UID) / "files" / "a.png").write_bytes(b"a")
_info(evil / str(UID), "a.png", "out/a.png")
os.symlink(evil, tmp_path / ".Trash", target_is_directory=True)
result = ts.empty([root], uid=UID)
assert result.ok and result.data["removed"] == 0
assert (evil / str(UID) / "files" / "a.png").exists()
def test_malformed_and_binary_trashinfo_ignored(self, tmp_path: Path):
root = tmp_path / "out"
root.mkdir()
td = _make_trash(tmp_path)
(td / "files" / "a.png").write_bytes(b"a")
(td / "info" / "a.png.trashinfo").write_bytes(b"\x00\x01\x02\xff garbage")
result = ts.empty([root], uid=UID)
assert result.ok and result.data["removed"] == 0
assert (td / "files" / "a.png").exists()
@pytest.mark.parametrize(
"crafted",
[".trashinfo", "..trashinfo", "...trashinfo"],
ids=["empty-stem", "dot", "dot-dot"],
)
def test_crafted_info_names_never_widen_removal(self, tmp_path: Path, crafted: str):
"""Crafted info basenames must not remove files/, the trash dir, or foreign entries."""
root = tmp_path / "out"
root.mkdir()
td = _make_trash(tmp_path)
(td / "info" / crafted).write_text("[Trash Info]\nPath=out/a.png\n")
(td / "files" / "foreign.png").write_bytes(b"foreign-payload")
foreign_info = _info(td, "foreign.png", "/elsewhere/foreign.png")
result = ts.empty([root], uid=UID)
assert result.ok and result.data["removed"] == 0
assert (td / "files" / "foreign.png").read_bytes() == b"foreign-payload"
assert foreign_info.exists()
assert (td / "info" / crafted).exists()
assert (td / "files").is_dir() and (td / "info").is_dir()
def test_trashinfo_larger_than_64kib_ignored(self, tmp_path: Path):
"""An oversized trashinfo (potential parse bomb) is treated as malformed."""
root = tmp_path / "out"
root.mkdir()
td = _make_trash(tmp_path)
(td / "files" / "big.png").write_bytes(b"b")
(td / "info" / "big.png.trashinfo").write_text(
f"[Trash Info]\nPath=out/big.png\nJunk={'y' * (70 * 1024)}\n", encoding="utf-8"
)
result = ts.empty([root], uid=UID)
assert result.ok and result.data["removed"] == 0
assert (td / "files" / "big.png").exists()
def test_percent_encoded_absolute_path_selected(self, tmp_path: Path):
"""Encoded Path= referencing the root is selected (happy path, not a hole)."""
root = tmp_path / "out"
root.mkdir()
td = _make_trash(tmp_path)
(td / "files" / "my cat.png").write_bytes(b"abc")
_info(td, "my cat.png", f"{root}/my%20cat.png")
result = ts.empty([root], uid=UID)
assert result.ok and result.data["removed"] == 1
def test_home_trash_entries_outside_roots_untouched(self, tmp_path: Path):
"""Home trash scanned for reporting but foreign entries stay put."""
root = tmp_path / "out"
root.mkdir()
xdg = tmp_path / "xdg"
td = xdg / "Trash"
(td / "info").mkdir(parents=True)
(td / "files").mkdir(parents=True)
(td / "files" / "notes.txt").write_bytes(b"not-majoor")
_info(td, "notes.txt", "/home/user/Documents/notes.txt")
scan = ts.scan([root], uid=UID, xdg_data_home=xdg)
assert scan.count == 0
result = ts.empty([root], uid=UID, xdg_data_home=xdg)
assert result.ok and result.data["removed"] == 0
assert (td / "files" / "notes.txt").read_bytes() == b"not-majoor"
+8
View File
@@ -345,6 +345,14 @@ export async function restoreDbBackup({ name = "", useLatest = false }: Record<s
return post(ENDPOINTS.DB_BACKUP_RESTORE, body);
}
export async function getTrashStatus() {
return get(ENDPOINTS.TRASH);
}
export async function emptyTrash() {
return post(ENDPOINTS.TRASH_EMPTY, {});
}
export async function startDuplicatesAnalysis(limit = 250) {
return post("/mjr/am/duplicates/analyze", {
limit: Math.max(10, Math.min(5000, Number(limit) || 250)),
+4
View File
@@ -146,6 +146,10 @@ export const ENDPOINTS = {
DB_BACKUP_SAVE: "/mjr/am/db/backup-save",
DB_BACKUP_RESTORE: "/mjr/am/db/backup-restore",
// Trash (freedesktop trash entries from Majoor roots)
TRASH: "/mjr/am/trash",
TRASH_EMPTY: "/mjr/am/trash/empty",
// Vector / Semantic search
VECTOR_SEARCH: "/mjr/am/vector/search",
VECTOR_SIMILAR: "/mjr/am/vector/similar",
+16
View File
@@ -367,6 +367,8 @@ const DICTIONARY: Record<string, Record<string, string>> = {
"btn.close": "Close",
"btn.dbSave": "Save DB",
"btn.dbRestore": "Restore DB",
"btn.emptyTrash": "Empty trash",
"btn.emptyTrashRunning": "Emptying...",
"btn.back": "Back",
"btn.up": "Up",
"btn.saving": "Saving...",
@@ -558,6 +560,10 @@ const DICTIONARY: Record<string, Record<string, string>> = {
"status.memoryPurgeStarted": "Memory purge started...",
"status.toolStatusChecking": "Tool status: checking...",
"status.resetIndexHint": "Reset index cache (requires allowResetIndex in settings).",
"status.emptyTrashHint":
"Permanently remove files Majoor sent to the freedesktop trash (requires allowDelete in settings).",
"status.emptyTrashDisabled":
"Empty trash is disabled. Enable 'allow_delete' in settings or set MAJOOR_ALLOW_DELETE=1.",
"status.scanningHint": "This may take a while",
"status.toolAvailable": "{tool} available",
"status.toolUnavailable": "{tool} unavailable",
@@ -1049,6 +1055,9 @@ const DICTIONARY: Record<string, Record<string, string>> = {
"dialog.newCollection.placeholder": "Collection name",
"dialog.resetIndex.title": "Reset index?",
"dialog.resetIndex.msg": "This will delete the database and rescan all files. Continue?",
"dialog.emptyTrash.title": "Empty trash?",
"dialog.emptyTrash.confirm":
"Permanently delete {count} trashed files ({size}) that came from Majoor's folders? This cannot be undone.",
"dialog.securityWarning":
"This looks like a system or very broad directory.\n\nAdding it can expose sensitive files via the viewer/custom roots feature.\n\nContinue?",
"dialog.securityWarningTitle": "Majoor: Security Warning",
@@ -1180,6 +1189,13 @@ const DICTIONARY: Record<string, Record<string, string>> = {
"toast.dbRestoreReplacing": "Replacing database files",
"toast.dbRestoreRescan": "Restarting scan",
"toast.dbRestoreSuccess": "Database backup restored",
"toast.trashIsEmpty": "Trash is empty",
"toast.trashRemoved": "{count} files removed ({size})",
"toast.trashFailures": "{count} entries could not be removed",
"toast.trashSkippedDirs": "{count} directories in the trash were not removed",
"toast.trashStatusFailed": "Failed to check trash",
"toast.trashEmptyFailed": "Failed to empty trash",
"toast.trashUnsupported": "Trash is not supported on this platform",
"toast.nameCollisionInView": "Name collision in current view",
"toast.fileRenamedSuccess": "File renamed successfully!",
"toast.fileRenameFailed": "Failed to rename file.",
+85
View File
@@ -20,6 +20,7 @@ import { APP_CONFIG } from "../../app/config.js";
import { comfyConfirm } from "../../app/dialogs.js";
import { comfyToast, recordToastHistory } from "../../app/toast.js";
import { t } from "../../app/i18n.js";
import { resolveTrashClient, runEmptyTrashAction } from "./emptyTrashAction.js";
import {
getRuntimeEnrichmentState,
setRuntimeEnrichmentState,
@@ -1214,6 +1215,90 @@ export function createStatusIndicator(options: Record<string, any> = {}): any {
}
};
const emptyTrashBtn = document.createElement("button");
emptyTrashBtn.type = "button";
emptyTrashBtn.textContent = t("btn.emptyTrash", "Empty trash");
emptyTrashBtn.title = t(
"status.emptyTrashHint",
"Permanently remove files Majoor sent to the freedesktop trash (requires allowDelete in settings).",
);
emptyTrashBtn.style.cssText = `
padding: 5px 12px;
font-size: 11px;
border-radius: 6px;
border: 1px solid rgba(255,167,38,0.45);
background: transparent;
color: inherit;
cursor: pointer;
transition: border 0.2s, background 0.2s;
`;
emptyTrashBtn.onmouseenter = () => {
emptyTrashBtn.style.borderColor = "rgba(255,167,38,0.85)";
emptyTrashBtn.style.background = "rgba(255,167,38,0.12)";
};
emptyTrashBtn.onmouseleave = () => {
emptyTrashBtn.style.borderColor = "rgba(255,167,38,0.45)";
emptyTrashBtn.style.background = "transparent";
};
void (async () => {
try {
const client = await resolveTrashClient();
if (typeof client.getTrashStatus !== "function") {
emptyTrashBtn.style.display = "none";
return;
}
const res = await client.getTrashStatus();
if (res?.ok && res?.data?.supported === false) {
emptyTrashBtn.style.display = "none";
return;
}
if (typeof client.getSecuritySettings === "function") {
const sec = await client.getSecuritySettings();
if (sec?.ok && sec?.data?.prefs && sec.data.prefs.allow_delete === false) {
emptyTrashBtn.disabled = true;
emptyTrashBtn.title = t(
"status.emptyTrashDisabled",
"Empty trash is disabled. Enable 'allow_delete' in settings or set MAJOOR_ALLOW_DELETE=1.",
);
}
}
} catch (e) {
console.debug?.(e);
}
})();
emptyTrashBtn.onclick = async (event) => {
event.stopPropagation();
setMaintenanceActive(true);
const originalText = emptyTrashBtn.textContent;
emptyTrashBtn.disabled = true;
resetBtn.disabled = true;
memoryPurgeBtn.disabled = true;
emptyTrashBtn.textContent = t("btn.emptyTrashRunning", "Emptying...");
try {
const client = await resolveTrashClient();
await runEmptyTrashAction({
getStatus: client.getTrashStatus,
empty: client.emptyTrash,
confirm: (message: string, title: string) =>
Promise.resolve(comfyConfirm(message, title)) as Promise<boolean>,
toast: (message, level, duration, options) =>
comfyToast(message, level, duration, options),
formatBytes,
translate: (key, fallback, params) => t(key, fallback, params),
});
} finally {
setMaintenanceActive(false);
emptyTrashBtn.disabled = false;
resetBtn.disabled = false;
memoryPurgeBtn.disabled = false;
emptyTrashBtn.textContent = originalText;
}
};
actionsRow.appendChild(backfillBtn);
actionsRow.appendChild(memoryPurgeBtn);
actionsRow.appendChild(resetBtn);
actionsRow.appendChild(emptyTrashBtn);
actionsRow.appendChild(backfillBtn);
actionsRow.appendChild(memoryPurgeBtn);
actionsRow.appendChild(resetBtn);
+169
View File
@@ -0,0 +1,169 @@
/**
* Empty-trash action flow for the Status panel.
*
* Kept free of DOM and module imports so the whole confirm → POST → toast
* flow is unit-testable in a plain Node environment: every dependency is
* injected. The Status panel wires real implementations; tests wire fakes.
*/
export interface EmptyTrashDeps {
getStatus: () => Promise<any>;
empty: () => Promise<any>;
confirm: (message: string, title: string) => Promise<boolean> | boolean;
toast: (message: string, level: string, duration?: number, options?: any) => void;
formatBytes: (bytes: number) => string;
translate: (key: string, fallback: string, params?: Record<string, any>) => string;
}
export type EmptyTrashOutcome = "empty" | "removed" | "cancelled" | "unsupported" | "error";
/**
* Resolve the trash client functions lazily so a partially mocked API module
* (e.g. in other suites' tests) never throws or logs during panel init.
* Missing functions are reported as undefined and treated as "unsupported".
*/
let _trashClientCache: Record<string, any> | null = null;
export async function resolveTrashClient(): Promise<Record<string, any>> {
if (_trashClientCache) return _trashClientCache;
const resolved: Record<string, any> = {};
try {
const mod: any = await import("../../api/client.js");
const available: string[] = Object.keys(mod);
for (const key of ["getTrashStatus", "emptyTrash", "getSecuritySettings"]) {
if (available.includes(key)) {
try {
const value = mod[key];
resolved[key] = typeof value === "function" ? value : undefined;
} catch {
resolved[key] = undefined;
}
}
}
} catch {
// Module unavailable — leave everything undefined (treated as unsupported).
}
_trashClientCache = resolved;
return resolved;
}
function _translateDefault(
deps: EmptyTrashDeps,
key: string,
fallback: string,
params?: Record<string, any>,
) {
try {
return deps.translate(key, fallback, params);
} catch {
return fallback;
}
}
export async function runEmptyTrashAction(deps: EmptyTrashDeps): Promise<EmptyTrashOutcome> {
const t = (key: string, fallback: string, params?: Record<string, any>) =>
_translateDefault(deps, key, fallback, params);
let status: any = null;
try {
status = await deps.getStatus();
} catch (error: any) {
deps.toast(
String(
error?.message || error || t("toast.trashStatusFailed", "Failed to check trash"),
),
"error",
4500,
);
return "error";
}
if (!status?.ok) {
deps.toast(
String(status?.error || t("toast.trashStatusFailed", "Failed to check trash")),
"error",
4500,
);
return "error";
}
const data = status?.data || {};
if (data?.supported === false) {
deps.toast(
t("toast.trashUnsupported", "Trash is not supported on this platform"),
"info",
3000,
);
return "unsupported";
}
const count = Number(data?.count) || 0;
if (!count) {
deps.toast(t("toast.trashIsEmpty", "Trash is empty"), "info", 2200);
return "empty";
}
const size = Number(data?.bytes) || 0;
const message = t(
"dialog.emptyTrash.confirm",
"Permanently delete {count} trashed files ({size}) that came from Majoor's folders? This cannot be undone.",
{ count, size: deps.formatBytes(size) },
);
let confirmed = false;
try {
confirmed = !!(await deps.confirm(message, t("dialog.emptyTrash.title", "Empty trash?")));
} catch {
confirmed = false;
}
if (!confirmed) return "cancelled";
let result: any = null;
try {
result = await deps.empty();
} catch (error: any) {
deps.toast(
String(error?.message || error || t("toast.trashEmptyFailed", "Failed to empty trash")),
"error",
4500,
);
return "error";
}
if (!result?.ok) {
deps.toast(
String(result?.error || t("toast.trashEmptyFailed", "Failed to empty trash")),
"error",
4500,
);
return "error";
}
const removed = Number(result?.data?.removed) || 0;
const failed = Array.isArray(result?.data?.failed) ? result.data.failed : [];
const skippedDirs = Number(result?.data?.skipped_dirs) || 0;
deps.toast(
t("toast.trashRemoved", "{count} files removed ({size})", {
count: removed,
size: deps.formatBytes(Number(result?.data?.bytes) || 0),
}),
"success",
3200,
);
if (failed.length) {
deps.toast(
t("toast.trashFailures", "{count} entries could not be removed", {
count: failed.length,
}),
"warning",
4500,
);
}
if (skippedDirs > 0) {
deps.toast(
t("toast.trashSkippedDirs", "{count} directories in the trash were not removed", {
count: skippedDirs,
}),
"warning",
4500,
);
}
return "removed";
}
+159
View File
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runEmptyTrashAction } from "../features/status/emptyTrashAction.js";
function makeDeps(overrides: Record<string, any> = {}) {
return {
getStatus: vi.fn(async () => ({
ok: true,
data: { supported: true, count: 3, bytes: 4096 },
})),
empty: vi.fn(async () => ({
ok: true,
data: { removed: 3, bytes: 4096, failed: [] },
})),
confirm: vi.fn(async (_message: string, _title: string) => true),
toast: vi.fn(),
formatBytes: vi.fn((n: number) => `${n} B`),
translate: vi.fn((_key: string, fallback: string, params?: Record<string, any>) => {
if (!params) return fallback;
let out = fallback;
for (const [k, v] of Object.entries(params)) {
out = out.split(`{${k}}`).join(String(v));
}
return out;
}),
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("runEmptyTrashAction", () => {
it("shows an info toast and never posts when the trash is empty", async () => {
const deps = makeDeps({
getStatus: vi.fn(async () => ({
ok: true,
data: { supported: true, count: 0, bytes: 0 },
})),
});
await expect(runEmptyTrashAction(deps)).resolves.toBe("empty");
expect(deps.toast).toHaveBeenCalledTimes(1);
expect(deps.toast.mock.calls[0][0]).toBe("Trash is empty");
expect(deps.toast.mock.calls[0][1]).toBe("info");
expect(deps.confirm).not.toHaveBeenCalled();
expect(deps.empty).not.toHaveBeenCalled();
});
it("asks for confirmation with count and size, then posts and toasts the result", async () => {
const deps = makeDeps();
await expect(runEmptyTrashAction(deps)).resolves.toBe("removed");
expect(deps.confirm).toHaveBeenCalledTimes(1);
const [message, title] = deps.confirm.mock.calls[0];
expect(message).toContain("3");
expect(message).toContain("4096 B");
expect(message).toContain("cannot be undone");
expect(title).toBe("Empty trash?");
expect(deps.empty).toHaveBeenCalledTimes(1);
expect(deps.toast.mock.calls[0]).toEqual(["3 files removed (4096 B)", "success", 3200]);
});
it("does not post when the user cancels the confirmation", async () => {
const deps = makeDeps({ confirm: vi.fn(async () => false) });
await expect(runEmptyTrashAction(deps)).resolves.toBe("cancelled");
expect(deps.empty).not.toHaveBeenCalled();
expect(deps.toast).not.toHaveBeenCalled();
});
it("adds a warning toast when some entries fail to remove", async () => {
const deps = makeDeps({
empty: vi.fn(async () => ({
ok: true,
data: {
removed: 2,
bytes: 2048,
failed: [{ name: "a.png", error: "permission denied" }],
},
})),
});
await expect(runEmptyTrashAction(deps)).resolves.toBe("removed");
expect(deps.toast).toHaveBeenCalledTimes(2);
expect(deps.toast.mock.calls[1][0]).toContain("1");
expect(deps.toast.mock.calls[1][1]).toBe("warning");
});
it("warns when directories in the trash are skipped", async () => {
const deps = makeDeps({
empty: vi.fn(async () => ({
ok: true,
data: { removed: 2, bytes: 2048, failed: [], skipped_dirs: 3 },
})),
});
await expect(runEmptyTrashAction(deps)).resolves.toBe("removed");
expect(deps.toast).toHaveBeenCalledTimes(2);
expect(deps.toast.mock.calls[1][0]).toContain("3");
expect(deps.toast.mock.calls[1][1]).toBe("warning");
});
it("reports unsupported platforms without posting", async () => {
const deps = makeDeps({
getStatus: vi.fn(async () => ({
ok: true,
data: { supported: false, count: 9, bytes: 9 },
})),
});
await expect(runEmptyTrashAction(deps)).resolves.toBe("unsupported");
expect(deps.confirm).not.toHaveBeenCalled();
expect(deps.empty).not.toHaveBeenCalled();
expect(deps.toast.mock.calls[0][1]).toBe("info");
});
it("surfaces GET failures as an error toast", async () => {
const deps = makeDeps({
getStatus: vi.fn(async () => ({ ok: false, error: "nope" })),
});
await expect(runEmptyTrashAction(deps)).resolves.toBe("error");
expect(deps.toast.mock.calls[0]).toEqual(["nope", "error", 4500]);
expect(deps.empty).not.toHaveBeenCalled();
});
it("surfaces thrown GET errors as an error toast", async () => {
const deps = makeDeps({
getStatus: vi.fn(async () => {
throw new Error("network down");
}),
});
await expect(runEmptyTrashAction(deps)).resolves.toBe("error");
expect(deps.toast.mock.calls[0][0]).toBe("network down");
});
it("surfaces POST failures as an error toast", async () => {
const deps = makeDeps({
empty: vi.fn(async () => ({ ok: false, error: "FORBIDDEN by policy" })),
});
await expect(runEmptyTrashAction(deps)).resolves.toBe("error");
expect(deps.toast.mock.calls[0]).toEqual(["FORBIDDEN by policy", "error", 4500]);
});
it("surfaces thrown POST errors as an error toast", async () => {
const deps = makeDeps({
empty: vi.fn(async () => {
throw new Error("boom");
}),
});
await expect(runEmptyTrashAction(deps)).resolves.toBe("error");
expect(deps.toast.mock.calls[0][0]).toBe("boom");
});
it("treats a throwing confirm dialog as a cancellation", async () => {
const deps = makeDeps({
confirm: vi.fn(async () => {
throw new Error("dialog closed");
}),
});
await expect(runEmptyTrashAction(deps)).resolves.toBe("cancelled");
expect(deps.empty).not.toHaveBeenCalled();
});
});