// ==UserScript==
// @name 海康威视NAS Docker手动下载镜像(单弹窗+改写pull/progress/cancel,保留原参数格式)
// @namespace tm-docker-native-rewrite-stable
// @license MIT
// @version 2.2.6
// @description 拦截镜像仓库“下载”;弹窗输入repo/tag;改写 image_pull / image_pull_progress / image_pull_cancel;保留如tid中的原始冒号格式
// @match *://*/web/easyconnect/*
// @run-at document-start
// @grant none
// ==/UserScript==
(function () {
"use strict";
if (window.__tm_native_rewrite_stable__) return;
window.__tm_native_rewrite_stable__ = true;
let pendingOverride = null; // { repo, tag, expireAt }
let lastPullInfo = null; // { repo, tag, ts }
let bypassClickOnce = false;
let modalOpen = false;
let lock = false;
function decodeDeep(v, maxRounds = 5) {
let s = v == null ? "" : String(v);
for (let i = 0; i < maxRounds; i++) {
try {
const d = decodeURIComponent(s);
if (d === s) break;
s = d;
} catch (_) {
break;
}
}
return s;
}
function normRepoTag(repo, tag) {
const r = decodeDeep(repo).trim();
const t = decodeDeep(tag || "latest").trim() || "latest";
return { repo: r, tag: t };
}
function encodeLoose(v) {
return encodeURIComponent(v)
.replace(/%2F/gi, "/")
.replace(/%3A/gi, ":")
.replace(/%40/gi, "@");
}
function saveLastPullInfo(repo, tag) {
const n = normRepoTag(repo, tag);
lastPullInfo = { repo: n.repo, tag: n.tag, ts: Date.now() };
try {
localStorage.setItem("__tm_last_pull_info__", JSON.stringify(lastPullInfo));
} catch (_) {}
}
try {
const s = localStorage.getItem("__tm_last_pull_info__");
if (s) {
const v = JSON.parse(s);
if (v && v.repo) {
const n = normRepoTag(v.repo, v.tag);
lastPullInfo = { repo: n.repo, tag: n.tag, ts: Number(v.ts || Date.now()) };
}
}
} catch (_) {}
function toast(msg, err) {
const el = document.createElement("div");
el.textContent = msg;
el.style.cssText = [
"position:fixed",
"right:20px",
"bottom:20px",
"z-index:1000001",
"background:" + (err ? "#ff4d4f" : "#1677ff"),
"color:#fff",
"padding:8px 12px",
"border-radius:8px",
"font-size:12px",
"box-shadow:0 6px 16px rgba(0,0,0,.2)"
].join(";");
(document.body || document.documentElement).appendChild(el);
setTimeout(() => el.remove(), 1800);
}
function isDockerRepoPage() {
const path = (location.pathname || "") + (location.hash || "");
if (/(docker|image|registry|mirror)/i.test(path)) return true;
const txt = document.body ? document.body.innerText || "" : "";
return /Docker/i.test(txt) && /镜像仓库|本地镜像/.test(txt);
}
function isTargetDownloadBtn(btn) {
if (!btn) return false;
if (btn.closest("[data-tm-dialog='1']")) return false;
const t = (btn.textContent || "").trim();
if (t !== "下载") return false;
const row =
btn.closest(".el-table__row,.list-item,.item,[class*='image'],[class*='docker'],tr,li") ||
btn.parentElement;
const ctx =
((row && row.innerText) ? row.innerText : "") +
"\n" +
(document.body ? (document.body.innerText || "").slice(0, 4000) : "");
return /镜像仓库|repo|tag|镜像名|Docker/i.test(ctx);
}
function guessRepoTag(btn) {
const row =
btn.closest(".el-table__row,.list-item,.item,[class*='image'],[class*='docker'],tr,li") ||
btn.parentElement;
const txt = ((row && row.innerText) ? row.innerText : "").trim();
let m = txt.match(/([a-zA-Z0-9._/-]+):([a-zA-Z0-9._-]+)/);
if (m) return normRepoTag(m[1], m[2]);
m = txt.match(/\b([a-zA-Z0-9._/-]{3,})\b/);
return normRepoTag(m ? m[1] : "", "latest");
}
function openPrompt(defaultRepo, defaultTag) {
return new Promise((resolve) => {
modalOpen = true;
const mask = document.createElement("div");
mask.setAttribute("data-tm-dialog", "1");
mask.style.cssText = [
"position:fixed",
"inset:0",
"background:rgba(0,0,0,.42)",
"z-index:1000000",
"display:flex",
"align-items:center",
"justify-content:center"
].join(";");
const box = document.createElement("div");
box.setAttribute("data-tm-dialog", "1");
box.style.cssText = [
"width:420px",
"max-width:calc(100vw - 40px)",
"background:#fff",
"border-radius:12px",
"padding:14px",
"box-sizing:border-box"
].join(";");
box.innerHTML = `
确认下载参数
repo
tag
`;
mask.appendChild(box);
(document.documentElement || document.body).appendChild(mask);
const repoEl = box.querySelector("#tm_repo");
const tagEl = box.querySelector("#tm_tag");
repoEl.value = decodeDeep(defaultRepo || "");
tagEl.value = decodeDeep(defaultTag || "latest");
repoEl.focus();
const close = (ret) => {
mask.remove();
modalOpen = false;
resolve(ret);
};
box.querySelector("#tm_cancel").onclick = () => close(null);
box.querySelector("#tm_ok").onclick = () => {
const n = normRepoTag(repoEl.value, tagEl.value);
if (!n.repo) return toast("repo 不能为空", true);
close(n);
};
mask.onclick = (e) => {
if (e.target === mask) close(null);
};
});
}
function getAction(urlLike) {
try {
const u = new URL(urlLike, location.origin);
if (!/\/rest\/1\.1\/docker$/i.test(u.pathname)) return "";
return (u.searchParams.get("action") || "").trim();
} catch (_) {
return "";
}
}
function setRepoTagRaw(urlLike, repo, tag) {
const u = new URL(urlLike, location.origin);
const n = normRepoTag(repo, tag);
let raw = u.search ? u.search.slice(1) : "";
if (raw) {
raw = raw
.split("&")
.filter(Boolean)
.filter((kv) => {
const eq = kv.indexOf("=");
const k = eq >= 0 ? kv.slice(0, eq) : kv;
try {
const kd = decodeURIComponent(k).trim();
return kd !== "repo" && kd !== "tag";
} catch (_) {
return k !== "repo" && k !== "tag";
}
})
.join("&");
}
const append = `repo=${encodeLoose(n.repo)}&tag=${encodeLoose(n.tag)}`;
const q = raw ? `${raw}&${append}` : append;
return `${u.origin}${u.pathname}?${q}`;
}
function rewriteUrl(urlLike) {
let u;
try {
u = new URL(urlLike, location.origin);
} catch (_) {
return urlLike;
}
const action = (u.searchParams.get("action") || "").trim();
const isTarget =
action === "image_pull" ||
action === "image_pull_progress" ||
action === "image_pull_cancel";
if (!isTarget) return urlLike;
if (action === "image_pull") {
if (pendingOverride && Date.now() <= pendingOverride.expireAt) {
const rewritten = setRepoTagRaw(urlLike, pendingOverride.repo, pendingOverride.tag);
pendingOverride = null;
try {
const uu = new URL(rewritten, location.origin);
const final = normRepoTag(
uu.searchParams.get("repo") || "",
uu.searchParams.get("tag") || "latest"
);
if (final.repo) saveLastPullInfo(final.repo, final.tag);
} catch (_) {}
return rewritten;
}
const final = normRepoTag(
u.searchParams.get("repo") || "",
u.searchParams.get("tag") || "latest"
);
if (final.repo) saveLastPullInfo(final.repo, final.tag);
return urlLike;
}
if (
(action === "image_pull_progress" || action === "image_pull_cancel") &&
lastPullInfo &&
lastPullInfo.repo
) {
return setRepoTagRaw(urlLike, lastPullInfo.repo, lastPullInfo.tag || "latest");
}
return urlLike;
}
const rawFetch = window.fetch;
window.fetch = function (input, init) {
try {
const oldUrl =
typeof input === "string" ? input : input && input.url ? input.url : "";
if (oldUrl) {
const action = getAction(oldUrl);
if (
action === "image_pull" ||
action === "image_pull_progress" ||
action === "image_pull_cancel"
) {
const newUrl = rewriteUrl(oldUrl);
if (typeof input === "string") input = newUrl;
else input = new Request(newUrl, input);
}
}
} catch (e) {
console.warn("[TM] fetch rewrite error:", e);
}
return rawFetch.call(this, input, init);
};
const rawOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
try {
if (url) {
const action = getAction(url);
if (
action === "image_pull" ||
action === "image_pull_progress" ||
action === "image_pull_cancel"
) {
url = rewriteUrl(url);
}
}
} catch (e) {
console.warn("[TM] xhr rewrite error:", e);
}
return rawOpen.call(this, method, url, async, user, password);
};
document.addEventListener(
"click",
async (ev) => {
if (lock || modalOpen) return;
if (bypassClickOnce) return;
if (!isDockerRepoPage()) return;
if (ev.target && ev.target.closest && ev.target.closest("[data-tm-dialog='1']")) return;
const btn = ev.target.closest("button,.el-button,[role='button']");
if (!btn || !isTargetDownloadBtn(btn)) return;
ev.preventDefault();
ev.stopPropagation();
ev.stopImmediatePropagation();
lock = true;
try {
const preset = guessRepoTag(btn);
const input = await openPrompt(preset.repo, preset.tag);
if (!input) return;
const n = normRepoTag(input.repo, input.tag);
pendingOverride = {
repo: n.repo,
tag: n.tag,
expireAt: Date.now() + 12000
};
bypassClickOnce = true;
btn.click();
toast(`已按参数继续:${n.repo}:${n.tag}`);
} finally {
setTimeout(() => {
bypassClickOnce = false;
lock = false;
}, 0);
}
},
true
);
})();