// ==UserScript==
// @name VSCode Marketplace VSIX Downloader
// @namespace http://tampermonkey.net/
// @version 1.6
// @description Adds a "Download VSIX" button to VSCode Marketplace extension pages, downloading the file as [ExtensionName][Version].vsix. Waits for dynamic content.
// @author Your Name/Adapted from Context
// @match https://marketplace.visualstudio.com/items?itemName=*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
if (document.getElementById('vsix-downloader-button')) {
console.log('VSIX Downloader: Button already exists (initial check).');
return;
}
const extensionDetails = {
version: "",
publisher: "",
identifier: "",
getDownloadUrl: function() {
if (!this.identifier || this.identifier.split(".").length < 2) {
console.error("VSIX Downloader: Invalid or missing identifier for download URL. Identifier:", this.identifier);
return "#error-missing-info-for-url";
}
const publisherFromName = this.identifier.split(".")[0];
const extensionNamePart = this.identifier.substring(publisherFromName.length + 1);
if (!publisherFromName || !extensionNamePart || !this.version) {
console.error("VSIX Downloader: Missing critical info for download URL. Publisher:", publisherFromName, "ExtName:", extensionNamePart, "Version:", this.version, "Full Identifier:", this.identifier);
return "#error-missing-info-for-url";
}
return [
"https://", publisherFromName, ".gallery.vsassets.io/_apis/public/gallery/publisher/",
publisherFromName, "/extension/", extensionNamePart, "/",
this.version, "/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage"
].join("");
},
getFileName: function() {
if (!this.identifier || !this.version || !this.identifier.includes('.')) {
console.error("VSIX Downloader: Missing critical info or invalid identifier for filename:", this);
return "error_unknown_extension.vsix";
}
// Extract extension name (part after the first dot in identifier)
const extensionName = this.identifier.substring(this.identifier.indexOf('.') + 1);
// Format: [title][version].vsix
// Sanitize parts to remove characters that might be problematic in filenames,
// though typical extension names and versions are usually fine.
// For this version, we'll concatenate directly as requested.
// Consider adding sanitization if issues arise with specific extension names/versions.
const cleanExtensionName = extensionName.replace(/[^a-zA-Z0-9_.-]/g, '_'); // Basic sanitization
const cleanVersion = this.version.replace(/[^a-zA-Z0-9_.-]/g, '_'); // Basic sanitization
return `${cleanExtensionName}${cleanVersion}.vsix`;
},
getDownloadButton: function() {
var button = document.createElement("a");
button.id = "vsix-downloader-button";
button.innerHTML = "Download VSIX";
button.href = "javascript:void(0);"; // Will be handled by onclick
button.style.fontFamily = "wf_segoe-ui, Helvetica Neue, Helvetica, Arial, Verdana";
button.style.display = "inline-block";
button.style.padding = "4px 8px";
button.style.background = "darkgreen";
button.style.color = "white";
button.style.fontWeight = "bold";
button.style.margin = "5px";
button.style.borderRadius = "3px";
button.style.textDecoration = "none";
const downloadUrl = this.getDownloadUrl();
const fileName = this.getFileName();
if (downloadUrl === "#error-missing-info-for-url" || fileName === "error_unknown_extension.vsix") {
button.innerHTML = "Error: Info Missing";
button.style.background = "darkred";
button.title = "Could not determine download URL or filename. Required information is missing or invalid.";
button.onclick = (e) => {
e.preventDefault();
alert("VSIX Downloader Error: Information to construct download URL or filename is missing. The page might not have fully loaded or there's an issue fetching extension details.");
};
return button;
}
button.setAttribute("data-url", downloadUrl);
button.setAttribute("data-filename", fileName);
button.title = `Download ${fileName}`;
button.onclick = function(event) {
event.preventDefault();
const clickedButton = event.target.closest('a'); // Ensure we get the button itself
const effectiveDownloadUrl = clickedButton.getAttribute("data-url");
const effectiveFileName = clickedButton.getAttribute("data-filename");
if (effectiveDownloadUrl === "#error-missing-info-for-url") {
clickedButton.innerHTML = "Error: Info Missing";
clickedButton.style.background = "darkred";
alert("VSIX Downloader Error: Information to construct download URL is missing (re-check).");
return;
}
// Store original click handler to restore it later
if (!clickedButton.hasOwnProperty('originalOnClick')) {
clickedButton.originalOnClick = clickedButton.onclick;
}
clickedButton.onclick = null; // Disable button during download
clickedButton.innerHTML = "Downloading VSIX...";
clickedButton.style.background = "darkorange";
var xhr = new XMLHttpRequest();
console.log("VSIX Downloader: Attempting to download from:", effectiveDownloadUrl, "as", effectiveFileName);
xhr.open("GET", effectiveDownloadUrl, true);
xhr.responseType = "blob";
xhr.onprogress = function(progressEvent) {
if (progressEvent.lengthComputable) {
var percentComplete = (progressEvent.loaded / progressEvent.total * 100).toFixed(0);
clickedButton.innerHTML = "Downloading VSIX... " + percentComplete + "%";
}
};
xhr.onload = function() {
if (this.status === 200) {
var blobResponse = this.response;
var downloadLink = document.createElement("a");
downloadLink.href = window.URL.createObjectURL(blobResponse);
downloadLink.download = effectiveFileName;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
window.URL.revokeObjectURL(downloadLink.href);
clickedButton.innerHTML = "Download VSIX"; // Reset
clickedButton.style.background = "darkgreen";
if (clickedButton.originalOnClick) {
clickedButton.onclick = clickedButton.originalOnClick;
} else { // Fallback if originalOnClick somehow not set
const currentButtonRef = document.getElementById("vsix-downloader-button");
if(currentButtonRef) currentButtonRef.onclick = extensionDetails.getDownloadButton().onclick; // Re-assign a fresh handler
}
} else {
clickedButton.innerHTML = "Error " + this.status + ". Retry?";
clickedButton.style.background = "darkred";
alert("Error " + this.status + " error receiving the document.");
if (clickedButton.originalOnClick) clickedButton.onclick = clickedButton.originalOnClick;
}
};
xhr.onerror = function() {
clickedButton.innerHTML = "Network Error. Retry?";
clickedButton.style.background = "darkred";
alert("Error " + xhr.status + " occurred while receiving the document (XHR onerror).");
if (clickedButton.originalOnClick) clickedButton.onclick = clickedButton.originalOnClick;
};
xhr.send();
};
return button;
}
};
const getTextFromAriaLabelledBy = (label) => {
const cell = document.querySelector(`td[aria-labelledby='${label}']`);
return cell ? cell.innerText.trim() : "";
};
function attemptAndSetup(logVerbose) {
extensionDetails.version = "";
extensionDetails.identifier = "";
// --- Data Gathering ---
let tempVersion = getTextFromAriaLabelledBy("version");
if (tempVersion) extensionDetails.version = tempVersion;
if (logVerbose && extensionDetails.version) console.log("VSIX Downloader: Version from aria-labelledby:", extensionDetails.version);
else if (logVerbose && !extensionDetails.version) console.warn("VSIX Downloader: Version not found via aria-labelledby.");
let publisherFromTableAria = getTextFromAriaLabelledBy("publisher");
let identifierFromTableAria = getTextFromAriaLabelledBy("identifier");
if (logVerbose && publisherFromTableAria) console.log("VSIX Downloader: Publisher from table (aria-labelledby):", publisherFromTableAria);
if (logVerbose && identifierFromTableAria) console.log("VSIX Downloader: Identifier from table (aria-labelledby):", identifierFromTableAria);
const urlParams = new URLSearchParams(window.location.search);
const itemNameFromUrl = urlParams.get('itemName');
if (itemNameFromUrl) {
extensionDetails.identifier = itemNameFromUrl;
if (logVerbose) console.log("VSIX Downloader: Identifier from URL (using as primary):", extensionDetails.identifier);
if (identifierFromTableAria && identifierFromTableAria !== extensionDetails.identifier && logVerbose) {
console.warn(`VSIX Downloader: Identifier from table ('${identifierFromTableAria}') differs from URL ('${extensionDetails.identifier}'). Prioritizing URL version.`);
}
} else if (identifierFromTableAria) {
extensionDetails.identifier = identifierFromTableAria;
if (logVerbose) console.log("VSIX Downloader: Identifier from table (aria-labelledby, URL itemName not found):", extensionDetails.identifier);
}
if (extensionDetails.identifier && extensionDetails.identifier.includes('.')) {
extensionDetails.publisher = extensionDetails.identifier.split('.')[0];
if (logVerbose) console.log("VSIX Downloader: Publisher derived from final identifier:", extensionDetails.publisher);
if (publisherFromTableAria && publisherFromTableAria !== extensionDetails.publisher && logVerbose) {
console.warn(`VSIX Downloader: Publisher from table (aria-labelledby '${publisherFromTableAria}') differs from identifier-derived ('${extensionDetails.publisher}'). Using identifier-derived for download URL.`);
}
} else if (publisherFromTableAria) {
extensionDetails.publisher = publisherFromTableAria;
if (logVerbose) console.log("VSIX Downloader: Publisher from table (aria-labelledby, identifier missing or not dot-separated):", extensionDetails.publisher);
}
if (!extensionDetails.version || !extensionDetails.publisher || !extensionDetails.identifier) {
if (logVerbose) console.warn("VSIX Downloader: Critical details missing after specific checks. Attempting broader table scan as fallback.");
const metadataTableRows = document.querySelectorAll(".ux-table-metadata tr");
if (metadataTableRows.length > 0) {
const keyMappings = { "Version": "version", "Publisher": "publisher", "Unique Identifier": "identifier" };
for (const row of metadataTableRows) {
if (row.cells.length === 2) {
const keyText = row.cells[0].innerText.trim();
const valueText = row.cells[1].innerText.trim();
if (keyMappings.hasOwnProperty(keyText)) {
const detailKey = keyMappings[keyText];
if (!extensionDetails[detailKey] && valueText) {
extensionDetails[detailKey] = valueText;
if (logVerbose) console.log(`VSIX Downloader: Fallback table scan - found ${detailKey}: ${valueText}`);
}
}
}
}
if (extensionDetails.identifier && extensionDetails.identifier.includes('.') &&
(!extensionDetails.publisher || extensionDetails.publisher !== extensionDetails.identifier.split('.')[0])) {
extensionDetails.publisher = extensionDetails.identifier.split('.')[0];
if (logVerbose) console.log("VSIX Downloader: Fallback scan - Re-derived publisher from identifier:", extensionDetails.publisher);
}
}
}
// --- End Data Gathering ---
let missingInfoForSetup = [];
if (!extensionDetails.identifier) missingInfoForSetup.push("identifier");
if (!extensionDetails.version) missingInfoForSetup.push("version");
if (extensionDetails.identifier && extensionDetails.identifier.split(".").length < 2) {
if (!missingInfoForSetup.includes("identifier")) {
missingInfoForSetup.push("identifier format (expected publisher.extensionName)");
}
}
if (missingInfoForSetup.length > 0) {
if (logVerbose) {
console.warn("VSIX Downloader: Waiting for critical info: " + missingInfoForSetup.join(", ") + ". Current details:", JSON.stringify(extensionDetails));
}
return false;
}
const potentialDownloadUrl = extensionDetails.getDownloadUrl();
const potentialFileName = extensionDetails.getFileName();
if (potentialDownloadUrl === "#error-missing-info-for-url" || potentialFileName === "error_unknown_extension.vsix") {
if (logVerbose) {
console.warn("VSIX Downloader: Cannot form valid download URL or filename yet. Details:", JSON.stringify(extensionDetails), "URL:", potentialDownloadUrl, "File:", potentialFileName);
}
return false;
}
const buttonInsertionPoint = document.querySelector(".vscode-moreinformation");
let actualInsertionParent = null;
let fallbackUsed = false;
if (buttonInsertionPoint && buttonInsertionPoint.parentElement) {
actualInsertionParent = buttonInsertionPoint.parentElement;
} else {
const fallbackPoint = document.querySelector(".gallery-banner .control-section") || document.querySelector(".gallery-banner");
if (fallbackPoint) {
actualInsertionParent = fallbackPoint;
fallbackUsed = true;
}
}
if (!actualInsertionParent) {
if (logVerbose) console.warn("VSIX Downloader: Button insertion point not yet available.");
return false;
}
if (document.getElementById('vsix-downloader-button')) {
return true;
}
const downloadButton = extensionDetails.getDownloadButton();
actualInsertionParent.appendChild(downloadButton);
if (fallbackUsed) {
console.warn("VSIX Downloader: Button added to a fallback location. Details:", JSON.stringify(extensionDetails));
} else {
console.log("VSIX Downloader: Button added successfully. Details:", JSON.stringify(extensionDetails));
}
return true;
}
let attempts = 0;
const maxAttempts = 30;
const retryInterval = 1000;
const intervalId = setInterval(() => {
attempts++;
let logThisAttemptVerbose = (attempts >= maxAttempts);
if (document.getElementById('vsix-downloader-button')) {
clearInterval(intervalId);
return;
}
if (attemptAndSetup(logThisAttemptVerbose || attempts === 1)) {
clearInterval(intervalId);
} else if (attempts >= maxAttempts) {
clearInterval(intervalId);
console.error("VSIX Downloader: Failed to add button after " + maxAttempts + " attempts. Necessary information, a valid download URL/filename, or DOM elements might not be available.");
if (!document.getElementById('vsix-downloader-button') && !document.getElementById('vsix-downloader-error-message')) {
let errorReason = "";
let missingInfoStrings = [];
if (!extensionDetails.version) missingInfoStrings.push("version");
if (!extensionDetails.identifier) {
missingInfoStrings.push("identifier");
} else if (extensionDetails.identifier.split(".").length < 2) {
if (!missingInfoStrings.includes("identifier")) missingInfoStrings.push("identifier format (publisher.extName)");
}
if (missingInfoStrings.length > 0) {
errorReason = `Failed to find ${missingInfoStrings.join(" and ")}.`;
} else if (extensionDetails.getDownloadUrl() === "#error-missing-info-for-url" || extensionDetails.getFileName() === "error_unknown_extension.vsix") {
errorReason = `Found some details, but could not form a valid download URL or filename. (Identifier: '${extensionDetails.identifier}', Version: '${extensionDetails.version}')`;
} else {
errorReason = `Timed out. Could not find insertion point or other unknown issue.`;
}
const errorDisplayPoint = document.querySelector(".vscode-moreinformation")?.parentElement || document.querySelector(".gallery-banner") || document.body;
if (errorDisplayPoint) {
const errorMsgElement = document.createElement('div');
errorMsgElement.id = "vsix-downloader-error-message";
errorMsgElement.textContent = `VSIX Downloader: ${errorReason} Cannot add download button.`;
errorMsgElement.style.color = 'red';
errorMsgElement.style.fontWeight = 'bold';
errorMsgElement.style.padding = '10px';
errorMsgElement.style.border = '1px solid red';
errorMsgElement.style.marginTop = '10px';
if (errorDisplayPoint.firstChild && errorDisplayPoint !== document.body) {
errorDisplayPoint.insertBefore(errorMsgElement, errorDisplayPoint.firstChild);
} else {
errorDisplayPoint.appendChild(errorMsgElement);
}
}
}
}
}, retryInterval);
})();