Publish YouTubeDL 1.4.5 and website on Gitea
Check extension and helper / check (push) Canceled after 0s

This commit is contained in:
DepressedCat committed 2026-10-10 23:53:17 +03:00
commit 5f2aad00f4
97 files changed
+8451

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
"""Build the small extension archive; the Windows helper is distributed separately."""
import binascii
import json
import struct
import zlib
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def png_chunk(kind, data):
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", binascii.crc32(kind + data) & 0xffffffff)
def icon(size):
rows = bytearray()
for y in range(size):
rows.append(0)
for x in range(size):
u, v = (x + 0.5) / size, (y + 0.5) / size
dx, dy = max(0.18 - u, 0, u - 0.82), max(0.18 - v, 0, v - 0.82)
alpha = 255 if dx * dx + dy * dy < 0.18 ** 2 else 0
color = (int(74 - 47 * v), int(205 - 52 * v), int(243 - 32 * v), alpha)
stem = 0.44 < u < 0.56 and 0.22 < v < 0.60
arrow = 0.46 < v < 0.71 and abs(u - 0.5) < (0.71 - v) * 1.15
tray = (0.26 < u < 0.74 and 0.76 < v < 0.82) or (0.26 < u < 0.32 or 0.68 < u < 0.74) and 0.66 < v < 0.82
if stem or arrow or tray: color = (8, 39, 57, alpha)
rows.extend(color)
return b"\x89PNG\r\n\x1a\n" + png_chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)) + png_chunk(b"IDAT", zlib.compress(bytes(rows))) + png_chunk(b"IEND", b"")
def main():
icons = ROOT / "extension" / "icons"
icons.mkdir(exist_ok=True)
for size in (16, 32, 48, 128): (icons / f"{size}.png").write_bytes(icon(size))
(ROOT / "dist").mkdir(exist_ok=True)
version = json.loads((ROOT / "extension" / "manifest.json").read_text(encoding="utf-8"))["version"]
destination = ROOT / "dist" / f"YouTubeDL-{version}.zip"
with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in (ROOT / "extension").rglob("*"):
if path.is_file() and path.suffix.lower() != ".exe" and "__pycache__" not in path.parts:
archive.write(path, str(path.relative_to(ROOT)))
for name in ("INSTALL.md", "LICENSE", "PRIVACY.md", "THIRD_PARTY.md"):
path = ROOT / name
if path.is_file(): archive.write(path, name)
print(destination)
if __name__ == "__main__": main()
+172
View File
@@ -0,0 +1,172 @@
"""Build the offline Windows x64 installer with only the system C# compiler.
Build-time downloads are cached and recorded. The resulting installer has no
network code, pip, or dependency on tools installed on the recipient's machine.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import hashlib
import json
import os
import shutil
import subprocess
import sys
import urllib.request
import zipfile
from pathlib import Path
import requests
ROOT = Path(__file__).resolve().parent.parent
CACHE = ROOT / ".build-cache"
BUILD = ROOT / ".build"
KIT = BUILD / "kit"
PYTHON = "3.13.14"
PYTHON_SHA256 = "90b4e5b9898b72d744650524bff92377c367f44bd5fbd09e3148656c080ad907"
NODE = "v24.21.0"
FFMPEG_ARCHIVE = "ffmpeg-master-latest-win64-gpl-shared.zip"
FFMPEG_SHA256 = "eb7afd4ecced72ab7d5d6fdd36dbc359173aa2daf6081817f7b678345898838b"
def digest(path):
value = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
value.update(chunk)
return value.hexdigest()
def fetch(url, name, expected=None):
destination = CACHE / name
if not destination.is_file():
print(f"Downloading {name}", flush=True)
partial = destination.with_suffix(destination.suffix + ".partial")
with requests.get(url, stream=True, timeout=90) as source, partial.open("wb") as output:
source.raise_for_status()
for chunk in source.iter_content(1024 * 1024): output.write(chunk)
partial.replace(destination)
actual = digest(destination)
if expected and actual != expected:
raise ValueError(f"Checksum mismatch: {name}")
return {"url": url, "file": name, "sha256": actual}
def safe_extract(archive, destination):
root = destination.resolve()
with zipfile.ZipFile(archive) as package:
for item in package.infolist():
candidate = (root / item.filename).resolve()
if root not in candidate.parents and candidate != root:
raise ValueError("Unsafe archive path")
package.extract(item, root)
def prepare():
CACHE.mkdir(exist_ok=True); BUILD.mkdir(exist_ok=True)
if KIT.exists():
if KIT.resolve().parent != BUILD.resolve() or KIT.is_symlink():
raise ValueError("Unsafe build cleanup path")
shutil.rmtree(KIT)
KIT.mkdir()
sources = [
(f"https://www.python.org/ftp/python/{PYTHON}/python-{PYTHON}-embed-amd64.zip", "python.zip", PYTHON_SHA256),
(f"https://nodejs.org/dist/{NODE}/node-{NODE}-win-x64.zip", "node.zip", None),
(f"https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/{FFMPEG_ARCHIVE}", FFMPEG_ARCHIVE, FFMPEG_SHA256),
]
# Node publishes a SHA256 manifest for every official release.
response = requests.get(f"https://nodejs.org/dist/{NODE}/SHASUMS256.txt", timeout=30); response.raise_for_status()
checksums = response.text
node_hash = next(line.split()[0] for line in checksums.splitlines() if line.endswith(f"node-{NODE}-win-x64.zip"))
sources[1] = (*sources[1][:2], node_hash)
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
records = list(executor.map(lambda item: fetch(*item), sources))
python = KIT / "runtime" / "python"
python.mkdir(parents=True)
safe_extract(CACHE / "python.zip", python)
(python / "python313._pth").write_text("python313.zip\n.\nLib/site-packages\n../../\nimport site\n", encoding="ascii")
# Verify the signed Python executable after extracting the official ZIP.
verifier = os.environ.get("YTDL_BUILD_POWERSHELL") or "powershell.exe"
signature = subprocess.check_output([verifier, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
"$taskSignature=Get-AuthenticodeSignature -LiteralPath $env:YTDL_VERIFY_PYTHON; Write-Output $taskSignature.Status"],
env={**os.environ, "YTDL_VERIFY_PYTHON": str(python / "python.exe")}, text=True).strip()
if signature != "Valid":
raise ValueError("Embedded Python signature verification failed: " + signature)
binary = KIT / "runtime" / "bin"; binary.mkdir()
licenses = KIT / "licenses"; licenses.mkdir()
with zipfile.ZipFile(CACHE / "node.zip") as package:
for name in ("node.exe", "LICENSE"):
item = next(entry for entry in package.namelist() if entry.endswith("/" + name))
(binary / name if name.endswith(".exe") else licenses / "Node-LICENSE.txt").write_bytes(package.read(item))
with zipfile.ZipFile(CACHE / FFMPEG_ARCHIVE) as package:
for item in package.namelist():
name = Path(item).name
if "/bin/" in item and (name.endswith(".dll") or name in {"ffmpeg.exe", "ffprobe.exe"}):
(binary / name).write_bytes(package.read(item))
elif name == "LICENSE.txt":
(licenses / "FFmpeg-LICENSE.txt").write_bytes(package.read(item))
wheels = CACHE / "wheels"; wheels.mkdir(exist_ok=True)
options = ["--platform", "win_amd64", "--python-version", "3.13", "--implementation", "cp",
"--abi", "cp313", "--abi", "abi3", "--abi", "none", "--only-binary", ":all:"]
requirement = "yt-dlp[default]==2026.8.19"
subprocess.run([sys.executable, "-m", "pip", "download", "--disable-pip-version-check", "-d", str(wheels), *options, requirement], check=True)
subprocess.run([sys.executable, "-m", "pip", "install", "--disable-pip-version-check", "--no-index", "--find-links", str(wheels),
"--target", str(python / "Lib" / "site-packages"), "--no-compile", "--ignore-requires-python", *options, requirement], check=True)
(licenses / "THIRD-PARTY.txt").write_text(
"Python: https://www.python.org/ ; license: runtime/python/LICENSE.txt\n"
"Node.js: https://nodejs.org/ ; license: Node-LICENSE.txt\n"
"FFmpeg Windows build and corresponding source/build instructions: https://github.com/yt-dlp/FFmpeg-Builds ; GPL license: FFmpeg-LICENSE.txt\n"
"yt-dlp and Python libraries: runtime/python/Lib/site-packages/*.dist-info (licenses and metadata)\n"
"yt-dlp source: https://github.com/yt-dlp/yt-dlp/tree/2026.08.19\n", encoding="utf-8")
(BUILD / "sources.json").write_text(json.dumps(records, indent=2), encoding="utf-8")
def build():
if not KIT.is_dir():
raise ValueError("Run with --prepare first.")
scripts = KIT / "runtime/python/Lib/site-packages/bin"
if scripts.exists():
if KIT.resolve() not in scripts.resolve().parents or scripts.is_symlink():
raise ValueError("Unsafe console-script cleanup path")
# pip's generated EXE launchers refer to the build interpreter. The helper
# imports these libraries directly, so no console launchers are shipped.
shutil.rmtree(scripts)
shutil.copytree(ROOT / "host", KIT / "host", dirs_exist_ok=True, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
sys.path.insert(0, str(ROOT))
from host.common import VERSION
version = VERSION
metadata = {"product": "YouTubeDL.Helper", "mode": "autonomous", "version": version, "platform": "windows-x64",
"tools": {"python": "runtime/python/python.exe", "node": "runtime/bin/node.exe", "ffmpeg": "runtime/bin/ffmpeg.exe", "ffprobe": "runtime/bin/ffprobe.exe"},
"sources": json.loads((BUILD / "sources.json").read_text(encoding="utf-8"))}
(KIT / "bundle.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")
environment = {**os.environ, "PATH": str(Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32"),
"PYTHONPATH": "C:/NONEXISTENT", "PYTHONHOME": "C:/NONEXISTENT", "PYTHONNOUSERSITE": "1"}
subprocess.run([str(KIT / "runtime/python/python.exe"), "-I", "-B", "-m", "host.bootstrap", "verify"], env=environment, check=True)
files = {str(path.relative_to(KIT)).replace("\\", "/"): digest(path) for path in KIT.rglob("*") if path.is_file() and path.name != "bundle-files.json"}
(KIT / "bundle-files.json").write_text(json.dumps(files, indent=2), encoding="utf-8")
archive = BUILD / "bundle.zip"
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as package:
for file in KIT.rglob("*"):
if file.is_file(): package.write(file, str(file.relative_to(KIT)))
code = (ROOT / "tools" / "setup.cs").read_text(encoding="utf-8").replace("@BUNDLE_VERSION@", version).replace("@BUNDLE_HASH@", digest(archive))
source = BUILD / "setup.cs"; source.write_text(code, encoding="utf-8-sig")
compiler = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "Microsoft.NET/Framework64/v4.0.30319/csc.exe"
common = [str(compiler), "/nologo", "/target:winexe", "/platform:x64", "/optimize+", "/r:System.Windows.Forms.dll",
"/r:System.Drawing.dll", "/r:System.IO.Compression.dll", "/r:System.IO.Compression.FileSystem.dll", "/r:System.Web.Extensions.dll",
f"/win32manifest:{ROOT / 'tools/setup.manifest'}"]
manager = BUILD / "YouTubeDL-Manager.exe"
subprocess.run([*common, f"/out:{manager}", str(source)], check=True)
(ROOT / "dist").mkdir(exist_ok=True)
installer = ROOT / "dist/YouTubeDL-Helper-Setup.exe"
subprocess.run([*common, "/define:SETUP", f"/resource:{archive},bundle.zip", f"/resource:{manager},manager.exe", f"/out:{installer}", str(source)], check=True)
destination = ROOT / "dist" / f"YouTubeDL-Helper-{version}-Setup.exe"
shutil.copy2(installer, destination)
(ROOT / "dist" / f"YouTubeDL-Helper-{version}-manifest.json").write_text(json.dumps({**metadata, "installerSHA256": digest(installer),
"installerBytes": installer.stat().st_size, "bundleSHA256": digest(archive)}, indent=2), encoding="utf-8")
print(f"Offline installer built: {destination} ({installer.stat().st_size / 1024**2:.1f} MiB)", flush=True)
if __name__ == "__main__":
parser = argparse.ArgumentParser(); parser.add_argument("--prepare", action="store_true"); parser.add_argument("--prepare-only", action="store_true")
args = parser.parse_args()
if args.prepare or args.prepare_only: prepare()
if not args.prepare_only: build()
+27
View File
@@ -0,0 +1,27 @@
// Capture the real extension renderer with the existing deterministic fixture.
// This never opens a personal browser profile or accesses real cookies.
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const browser = await chromium.launch({ headless: true, executablePath: process.env.YTDL_TEST_BROWSER || "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" });
try {
const context = await browser.newContext({ viewport: { width: 520, height: 1080 }, reducedMotion: "reduce" });
await context.addInitScript({ path: path.join(root, "tests/ui-fixture.js") });
await context.route("**/*", async route => {
const url = new URL(route.request().url());
const name = path.basename(url.pathname);
if (url.hostname !== "ui.ytdl.test" || !["downloader.html", "downloader.js", "onboarding.js", "ui-connection.js", "theme-init.js", "styles.css", "shared.js", "session.js"].includes(name)) return route.abort();
return route.fulfill({ body: await fs.readFile(path.join(root,"extension",name)), contentType: name.endsWith(".html") ? "text/html" : name.endsWith(".css") ? "text/css" : "application/javascript" });
});
const page = await context.newPage();
await fs.mkdir(path.join(root,"site/assets"),{recursive:true});
for (const theme of ["dark","light"]) {
await page.goto(`https://ui.ytdl.test/downloader.html?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DaircAruvnKk&theme=${theme}`);
await page.locator("#download-form").waitFor({state:"visible"});
await page.locator("#quality").selectOption("137");
await page.locator("#folder").fill("C:\\Videos");
await page.locator(".app").screenshot({path:path.join(root,`site/assets/menu-${theme}.png`),animations:"disabled"});
}
} finally { await browser.close(); }
+86
View File
@@ -0,0 +1,86 @@
"""Publish license texts and pinned upstream source materials alongside the helper.
Run after tools/build_bundle.py. This does not alter or install the helper.
Third-party FFmpeg dependency download/build recipes are preserved verbatim.
"""
from __future__ import annotations
import concurrent.futures
import hashlib
import json
import subprocess
import zipfile
from pathlib import Path
import requests
ROOT = Path(__file__).resolve().parent.parent
KIT = ROOT / ".build/kit"
CACHE = ROOT / ".build-cache/release-sources"
FFMPEG_REVISION = "17ec99894249a68444d145ea8870713a4fb7f342"
BUILD_REVISION = "8fc3fe09d1d84a10cecc2b08706e303e27e64834"
def download(item):
CACHE.mkdir(parents=True, exist_ok=True)
target = CACHE / item["file"]
if not target.is_file():
response = requests.get(item["url"], timeout=(20, 180))
response.raise_for_status()
target.write_bytes(response.content)
data = target.read_bytes()
checksum = hashlib.sha256(data).hexdigest()
if item.get("expectedSHA256") and item["expectedSHA256"] != checksum:
raise ValueError(f"Source hash mismatch: {item['file']}")
print(f"Source: {item['file']} ({len(data)} bytes)", flush=True)
return {**item, "sha256": checksum, "bytes": len(data)}
def main():
manifest = json.loads((ROOT / "dist/YouTubeDL-Helper-manifest.json").read_text(encoding="utf-8"))
sources = [
{"file": f"FFmpeg-{FFMPEG_REVISION}.tar.gz", "url": f"https://codeload.github.com/FFmpeg/FFmpeg/tar.gz/{FFMPEG_REVISION}", "component": "FFmpeg"},
{"file": f"FFmpeg-Builds-{BUILD_REVISION}.tar.gz", "url": f"https://codeload.github.com/yt-dlp/FFmpeg-Builds/tar.gz/{BUILD_REVISION}", "component": "FFmpeg build and dependency source recipes"},
]
for metadata in sorted((KIT / "runtime/python/Lib/site-packages").glob("*.dist-info/METADATA")):
fields = dict(line.split(": ", 1) for line in metadata.read_text(encoding="utf-8").splitlines() if line.startswith(("Name: ", "Version: ")))
name, version = fields["Name"], fields["Version"]
response = requests.get(f"https://pypi.org/pypi/{name}/{version}/json", timeout=30)
response.raise_for_status()
sdist = next((file for file in response.json()["urls"] if file["packagetype"] == "sdist"), None)
if sdist:
sources.append({"file": sdist["filename"], "url": sdist["url"], "expectedSHA256": sdist["digests"]["sha256"], "component": f"{name} {version}"})
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
records = list(pool.map(download, sources))
configuration = subprocess.run([str(KIT / "runtime/bin/ffmpeg.exe"), "-version"], capture_output=True, text=True, check=True).stdout
destination = ROOT / "dist/YouTubeDL-ThirdParty-Materials.zip"
with zipfile.ZipFile(destination,"w",zipfile.ZIP_DEFLATED) as archive:
for name in ("THIRD_PARTY.md", "LICENSE", "CONTRIBUTING.md"):
archive.write(ROOT / name, name)
archive.writestr("helper-manifest.json", json.dumps(manifest,indent=2))
archive.writestr("source-index.json", json.dumps(records,indent=2))
archive.writestr("FFmpeg-configuration.txt", configuration)
archive.writestr("SOURCE-README.txt", f"""YouTubeDL helper {manifest['version']}: upstream source materials and licenses.
The exact FFmpeg source revision is in sources/; FFmpeg-Builds scripts contain
the pinned download and build recipes for its third-party libraries.
Extract the FFmpeg-Builds source archive and follow its Docker build instructions.
Run ./download.sh win64 gpl-shared to fetch dependency sources; build.sh is the
corresponding build recipe. Read scripts.d, variants and util for configuration.
The supplied FFmpeg binary is unchanged from yt-dlp/FFmpeg-Builds.
Python and Node.js official sources:
https://www.python.org/ftp/python/3.13.14/Python-3.13.14.tgz
https://nodejs.org/dist/v24.21.0/node-v24.21.0.tar.xz
Python package source distributions are included where PyPI provides them.
The original installer source and Native Messaging helper source are in the
YouTubeDL repository. The root LICENSE governs original YouTubeDL materials.
Third-party components are distributed under their own licenses; see THIRD_PARTY.md.
""")
for record in records:
archive.write(CACHE / record["file"], "sources/" + record["file"])
for file in KIT.rglob("*"):
if not file.is_file(): continue
relative = file.relative_to(KIT)
if "licenses" in relative.parts or (".dist-info" in str(relative) and (file.name == "METADATA" or any(word in str(relative).lower() for word in ("license","copying","notice")))) or file.name == "LICENSE.txt":
archive.write(file,"licenses/" + str(relative).replace("\\","/"))
print(f"Materials: {destination.name} ({destination.stat().st_size} bytes)", flush=True)
if __name__ == "__main__": main()
+16
View File
@@ -0,0 +1,16 @@
param([Parameter(Mandatory=$true)][string]$InstallerPath)
$ErrorActionPreference = 'Stop'
$taskInstaller = (Resolve-Path -LiteralPath $InstallerPath).Path
$taskPreviousFlag = $env:YTDL_INSTALL_NONINTERACTIVE
try {
if ($taskInstaller.EndsWith('.exe', [StringComparison]::OrdinalIgnoreCase)) {
$taskProcess = Start-Process -FilePath $taskInstaller -ArgumentList '--silent' -WindowStyle Hidden -Wait -PassThru
if ($taskProcess.ExitCode -ne 0) { throw "Offline installer failed: $($taskProcess.ExitCode)" }
return
}
$env:YTDL_INSTALL_NONINTERACTIVE = '1'
& $taskInstaller
if ($LASTEXITCODE -ne 0) { throw "Helper installer failed: $LASTEXITCODE" }
} finally {
$env:YTDL_INSTALL_NONINTERACTIVE = $taskPreviousFlag
}
+412
View File
@@ -0,0 +1,412 @@
// Offline installer and ownership-scoped uninstaller. .NET Framework is part of Windows.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
using System.Windows.Forms;
using Microsoft.Win32;
internal static class Program
{
const string Version = "@BUNDLE_VERSION@";
const string BundleHash = "@BUNDLE_HASH@";
const string Product = "YouTubeDL.Helper";
const string Host = "com.youtubedl.native";
const string UninstallKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\YouTubeDL.Helper";
static readonly string[] Browsers = { @"Google\Chrome", @"Microsoft\Edge", "Chromium" };
static readonly JavaScriptSerializer Json = new JavaScriptSerializer { MaxJsonLength = 16 * 1024 * 1024 };
[STAThread]
public static int Main(string[] args)
{
Console.SetOut(new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)) { AutoFlush = true });
Console.SetError(new StreamWriter(Console.OpenStandardError(), new UTF8Encoding(false)) { AutoFlush = true });
AppContext.SetSwitch("Switch.System.IO.UseLegacyPathHandling", false);
AppContext.SetSwitch("Switch.System.IO.BlockLongPaths", false);
if (args.Contains("--pick-folder"))
{
Application.EnableVisualStyles();
using (var dialog = new FolderBrowserDialog())
using (var owner = new Form { TopMost = true, ShowInTaskbar = false, Opacity = 0 })
{
string initial = Value(args, "--initial-folder");
if (initial != null) dialog.SelectedPath = Json.Deserialize<string>(Encoding.UTF8.GetString(Convert.FromBase64String(initial)));
dialog.Description = "YouTubeDL — папка сохранения"; dialog.ShowNewFolderButton = true;
owner.Show();
Console.WriteLine(Json.Serialize(dialog.ShowDialog(owner) == DialogResult.OK ? dialog.SelectedPath : null));
return 0;
}
}
bool silent = args.Contains("--silent");
bool uninstall = args.Contains("--uninstall");
string explicitRoot = Value(args, "--target-root");
string appRoot = Path.GetFullPath(explicitRoot ?? (uninstall ? Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "YouTubeDL", "Helper"))).TrimEnd(Path.DirectorySeparatorChar);
Action<Action<int, string>> work = report => Run(appRoot, args, uninstall, report);
if (silent)
{
string previous = null;
try { work((percent, message) => { if (message != previous) Console.WriteLine(message); previous = message; }); return 0; }
catch (Exception error) { Console.Error.WriteLine(error.ToString()); return 1; }
}
Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false);
var window = new SetupWindow(work, uninstall);
Application.Run(window);
return window.ExitCode;
}
static string Value(string[] args, string key)
{
int index = Array.IndexOf(args, key);
return index >= 0 && index + 1 < args.Length ? args[index + 1] : null;
}
static void Run(string root, string[] args, bool uninstall, Action<int, string> report)
{
if (Path.GetPathRoot(root).TrimEnd('\\') == root.TrimEnd('\\') || root == Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
throw new Exception("Некорректная папка установки.");
if (Directory.Exists(root) && (File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0)
throw new Exception("Папка установки не должна быть символической ссылкой.");
string waiting = Value(args, "--wait-pid");
if (waiting != null)
{
try { using (var parent = Process.GetProcessById(Int32.Parse(waiting))) { if (!parent.WaitForExit(30000)) throw new Exception("Помощник ещё работает. Закройте меню и повторите удаление."); } }
catch (ArgumentException) { }
}
if (!uninstall && !args.Contains("--finish-uninstall"))
{
Directory.CreateDirectory(root);
if (!args.Contains("--activate-release"))
{
// AppData virtualization can merge an existing real directory
// with newly created children elsewhere. Resolve a fresh child
// to find the directory where this install will actually write.
string probeName = ".ytdl-location-" + Guid.NewGuid().ToString("N");
string probe = Inside(root, probeName);
Directory.CreateDirectory(probe);
string physicalProbe = PhysicalDirectory(probe);
if (!String.Equals(Path.GetFileName(physicalProbe), probeName, StringComparison.OrdinalIgnoreCase))
throw new Exception("Не удалось определить папку установки.");
root = Path.GetDirectoryName(physicalProbe);
Directory.Delete(physicalProbe, false);
}
}
// Keep the manager, marker and runtime in the same physical directory.
root = PhysicalDirectory(root).TrimEnd(Path.DirectorySeparatorChar);
string key;
using (var sha = SHA256.Create()) key = BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(PhysicalDirectory(root).ToLowerInvariant()))).Replace("-", "").Substring(0, 16);
using (var mutex = new Mutex(false, "Local\\YouTubeDL.Setup." + key))
{
if (!mutex.WaitOne(0)) throw new Exception("Другая установка или удаление уже выполняется.");
try
{
if (args.Contains("--activate-release")) Activate(root, args, report);
else if (args.Contains("--finish-uninstall")) FinishRemoval(root, report);
else if (uninstall) Uninstall(root, report);
else
{
#if SETUP
Install(root, !args.Contains("--no-register"), report);
#else
throw new Exception("Для установки запустите YouTubeDL-Helper-Setup.exe.");
#endif
}
}
finally { mutex.ReleaseMutex(); }
}
}
static string Inside(string root, string relative)
{
if (String.IsNullOrWhiteSpace(relative) || Path.IsPathRooted(relative)) throw new Exception("Некорректный путь в комплекте.");
string full = Path.GetFullPath(Path.Combine(root, relative));
if (!full.StartsWith(root.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase)) throw new Exception("Путь выходит за папку комплекта.");
for (var parent = new DirectoryInfo(Path.GetDirectoryName(full)); parent != null && parent.FullName.Length >= root.Length; parent = parent.Parent)
if (parent.Exists && (parent.Attributes & FileAttributes.ReparsePoint) != 0) throw new Exception("Символические ссылки в папке комплекта не поддерживаются.");
if (File.Exists(full) && (File.GetAttributes(full) & FileAttributes.ReparsePoint) != 0) throw new Exception("Файл комплекта заменён символической ссылкой.");
return full;
}
static string Hash(Stream stream)
{ using (var sha = SHA256.Create()) return BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant(); }
static Dictionary<string, object> ObjectFile(string path)
{ return Json.Deserialize<Dictionary<string, object>>(File.ReadAllText(path, Encoding.UTF8)); }
#if SETUP
static void Install(string root, bool register, Action<int, string> report)
{
report(2, "Проверяю автономный комплект…");
string releases = Path.Combine(root, "releases"); Directory.CreateDirectory(releases);
string stage = Inside(releases, Version + "-" + Guid.NewGuid().ToString("N").Substring(0, 8)); Directory.CreateDirectory(stage);
string archive = Inside(stage, "payload.zip");
string release = null;
bool installed = false;
try
{
using (var input = Assembly.GetExecutingAssembly().GetManifestResourceStream("bundle.zip"))
using (var output = File.Create(archive)) input.CopyTo(output);
using (var input = File.OpenRead(archive)) if (Hash(input) != BundleHash) throw new Exception("Контрольная сумма комплекта не совпала.");
using (var zip = ZipFile.OpenRead(archive))
{
long total = zip.Entries.Sum(entry => entry.Length), written = 0;
if (total > 1024L * 1024 * 1024) throw new Exception("Некорректный размер комплекта.");
foreach (var entry in zip.Entries)
{
if (entry.FullName.EndsWith("/")) continue;
if (((entry.ExternalAttributes >> 16) & 0xF000) == 0xA000) throw new Exception("Символическая ссылка в архиве.");
string destination = Inside(stage, entry.FullName);
Directory.CreateDirectory(Path.GetDirectoryName(destination));
using (var input = entry.Open()) using (var output = File.Create(destination)) input.CopyTo(output);
written += entry.Length;
report(5 + (int)(written * 70 / total), "Распаковываю компоненты…");
}
}
File.Delete(archive);
report(78, "Проверяю Python, загрузчик, JavaScript и FFmpeg…");
// Virus scanners can briefly hold a newly extracted EXE or DLL.
for (int attempt = 0; ; attempt++)
{
try { Python(stage, "verify"); break; }
catch { if (attempt == 2) throw; Thread.Sleep(700 * (attempt + 1)); }
}
release = stage;
Python(release, "configure");
string manager = Inside(root, "YouTubeDL-Manager.exe");
using (var input = Assembly.GetExecutingAssembly().GetManifestResourceStream("manager.exe"))
using (var output = File.Create(manager)) input.CopyTo(output);
string marker = Inside(root, "installation.json");
string history = Inside(root, @"data\completed-jobs.json");
string previousHistory = Inside(root, @".runtime\completed-jobs.json");
if (!File.Exists(history) && File.Exists(previousHistory))
{ Directory.CreateDirectory(Path.GetDirectoryName(history)); File.Copy(previousHistory, history); }
File.WriteAllText(marker, Json.Serialize(new { product = Product, version = Version, current = release, registered = register }), Encoding.UTF8);
string updates = Inside(root, @"data\update-settings.json");
if (File.Exists(updates))
{ var options = ObjectFile(updates); options["lastCheck"] = 0; File.WriteAllText(updates, Json.Serialize(options), new UTF8Encoding(false)); }
if (register)
{
report(92, "Подключаю помощник к Edge и Chrome…");
foreach (string browser in Browsers)
using (var registry = Registry.CurrentUser.CreateSubKey(@"Software\" + browser + @"\NativeMessagingHosts\" + Host))
registry.SetValue("", Path.Combine(release, @".runtime\native-host.json"));
using (var registry = Registry.CurrentUser.CreateSubKey(UninstallKey))
{
registry.SetValue("DisplayName", "YouTubeDL Helper"); registry.SetValue("DisplayVersion", Version);
registry.SetValue("Publisher", "YouTubeDL"); registry.SetValue("InstallLocation", root);
registry.SetValue("UninstallString", "\"" + manager + "\" --uninstall");
registry.SetValue("QuietUninstallString", "\"" + manager + "\" --uninstall --silent");
registry.SetValue("NoModify", 1); registry.SetValue("NoRepair", 1);
}
}
installed = true;
report(100, "Готово. Вернитесь в YouTubeDL и нажмите «Запустить».");
}
finally
{
// Only this freshly generated, unpublished folder may be removed.
// Cleanup must not hide the original validation/installation error.
try
{
if (!installed && Directory.Exists(stage) && (File.GetAttributes(stage) & FileAttributes.ReparsePoint) == 0)
{
var candidates = Directory.EnumerateFileSystemEntries(stage, "*", SearchOption.AllDirectories).ToArray();
if (candidates.All(path => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == 0)) Directory.Delete(stage, true);
}
}
catch (IOException) { } catch (UnauthorizedAccessException) { }
}
}
#endif
static void Activate(string root, string[] args, Action<int, string> report)
{
string marker = Inside(root, "installation.json");
var previous = ObjectFile(marker);
if ((string)previous["product"] != Product) throw new Exception("Установленный комплект не найден.");
string expected = PhysicalDirectory(Value(args, "--expected-current"));
string current = PhysicalDirectory((string)previous["current"]);
if (!String.Equals(current, expected, StringComparison.OrdinalIgnoreCase)) throw new Exception("Помощник уже изменён другой установкой. Повторите проверку обновлений.");
string stage = PhysicalDirectory(Path.GetFullPath(Value(args, "--activate-release")));
if (!String.Equals(Path.GetDirectoryName(stage), PhysicalDirectory(Inside(root, "releases")), StringComparison.OrdinalIgnoreCase)) throw new Exception("Папка обновления вне комплекта.");
var metadata = ObjectFile(Inside(stage, "bundle.json"));
if ((string)metadata["product"] != Product || (string)metadata["mode"] != "autonomous") throw new Exception("Некорректный комплект обновления.");
report(10, "Проверяю контрольные суммы обновления…");
foreach (var entry in ObjectFile(Inside(stage, "bundle-files.json")))
using (var input = File.OpenRead(Inside(stage, entry.Key)))
if (Hash(input) != (string)entry.Value) throw new Exception("Повреждён компонент обновления: " + entry.Key);
for (int attempt = 0; ; attempt++)
{ try { Python(stage, "verify"); break; } catch { if (attempt == 2) throw; Thread.Sleep(700 * (attempt + 1)); } }
Python(stage, "configure");
var registrations = new Dictionary<string, string>();
string nextMarker = Inside(root, "installation.next.json");
try
{
if ((bool)previous["registered"])
foreach (string browser in Browsers)
{
string name = @"Software\" + browser + @"\NativeMessagingHosts\" + Host;
using (var registry = Registry.CurrentUser.OpenSubKey(name, true))
{
string value = registry == null ? null : registry.GetValue("") as string;
if (value == null || !String.Equals(PhysicalDirectory(Path.GetDirectoryName(value)), PhysicalDirectory(Path.Combine(current, ".runtime")), StringComparison.OrdinalIgnoreCase)) continue;
registrations[name] = value; registry.SetValue("", Path.Combine(stage, @".runtime\native-host.json"));
}
}
File.WriteAllText(nextMarker, Json.Serialize(new { product = Product, version = (string)metadata["version"], current = stage, registered = (bool)previous["registered"] }), Encoding.UTF8);
File.Replace(nextMarker, marker, null);
}
catch
{
foreach (var entry in registrations) using (var registry = Registry.CurrentUser.CreateSubKey(entry.Key)) registry.SetValue("", entry.Value);
if (File.Exists(nextMarker)) File.Delete(nextMarker);
throw;
}
report(100, "Новые компоненты подключены.");
}
static void Python(string root, string action)
{
var start = new ProcessStartInfo(Inside(root, @"runtime\python\python.exe"), "-I -X utf8 -B -m host.bootstrap " + action)
{ UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 };
start.EnvironmentVariables["PYTHONPATH"] = "C:\\NONEXISTENT"; start.EnvironmentVariables["PYTHONHOME"] = "C:\\NONEXISTENT";
start.EnvironmentVariables["PYTHONDONTWRITEBYTECODE"] = "1";
start.EnvironmentVariables["PATH"] = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32");
using (var process = Process.Start(start))
{
var stdout = process.StandardOutput.ReadToEndAsync(); var stderr = process.StandardError.ReadToEndAsync();
if (!process.WaitForExit(90000)) throw new Exception("Проверка комплекта заняла слишком много времени.");
if (process.ExitCode != 0) throw new Exception("Комплект не прошёл проверку: " + stderr.Result + stdout.Result);
}
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern Microsoft.Win32.SafeHandles.SafeFileHandle CreateFileW(string path, uint access, uint share, IntPtr security, uint disposition, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern uint GetFinalPathNameByHandleW(Microsoft.Win32.SafeHandles.SafeFileHandle file, StringBuilder name, uint length, uint flags);
static string PhysicalDirectory(string path)
{
using (var handle = CreateFileW(path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero))
{
if (handle.IsInvalid) return Path.GetFullPath(path);
var name = new StringBuilder(32768);
if (GetFinalPathNameByHandleW(handle, name, (uint)name.Capacity, 0) == 0) return Path.GetFullPath(path);
string value = name.ToString();
return value.StartsWith(@"\\?\UNC\") ? @"\\" + value.Substring(8) : value.StartsWith(@"\\?\") ? value.Substring(4) : value;
}
}
static void Uninstall(string root, Action<int, string> report)
{
string marker = Inside(root, "installation.json");
if (!File.Exists(marker) || (string)ObjectFile(marker)["product"] != Product) throw new Exception("Установленный комплект YouTubeDL не найден.");
string physicalRoot = PhysicalDirectory(root);
report(5, "Отключаю помощник…");
foreach (string browser in Browsers)
{
string name = @"Software\" + browser + @"\NativeMessagingHosts\" + Host;
using (var registry = Registry.CurrentUser.OpenSubKey(name))
{
string value = registry == null ? null : registry.GetValue("") as string;
if (value == null || !PhysicalDirectory(Path.GetDirectoryName(value)).StartsWith(physicalRoot + "\\releases\\", StringComparison.OrdinalIgnoreCase)) continue;
}
Registry.CurrentUser.DeleteSubKey(name, false);
}
using (var registry = Registry.CurrentUser.OpenSubKey(UninstallKey))
{ if (registry != null && registry.GetValue("InstallLocation") is string && String.Equals(PhysicalDirectory((string)registry.GetValue("InstallLocation")), physicalRoot, StringComparison.OrdinalIgnoreCase)) Registry.CurrentUser.DeleteSubKeyTree(UninstallKey, false); }
string releases = Inside(root, "releases");
string[] packages = Directory.Exists(releases) ? Directory.GetDirectories(releases) : new string[0];
int done = 0;
foreach (string release in packages)
{
Inside(root, release.Substring(root.Length + 1) + "\\bundle.json");
if (!File.Exists(Path.Combine(release, "bundle.json")) || !File.Exists(Path.Combine(release, "bundle-files.json")) || (string)ObjectFile(Path.Combine(release, "bundle.json"))["product"] != Product) continue;
var records = ObjectFile(Path.Combine(release, "bundle-files.json"));
var directories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string relative in records.Keys.Concat(new[] { "bundle-files.json", ".runtime/native-host.json", ".runtime/launch-host.cmd" }))
{
string path = Inside(release, relative);
if (File.Exists(path)) File.Delete(path);
for (string parent = Path.GetDirectoryName(path); parent != null && parent.StartsWith(release, StringComparison.OrdinalIgnoreCase); parent = Path.GetDirectoryName(parent)) directories.Add(parent);
}
foreach (string directory in directories.OrderByDescending(path => path.Length)) TryRemoveEmpty(directory);
report(10 + (++done * 80 / Math.Max(1, packages.Length)), "Удаляю встроенные компоненты…");
}
TryRemoveEmpty(releases);
string manager = Inside(root, "YouTubeDL-Manager.exe");
// Run the final cleanup outside the installed directory, after this EXE exits.
// The same mutex and an installation-marker check prevent a reinstall race.
if (File.Exists(manager))
{
string pending = Inside(root, "uninstall-pending.json");
File.WriteAllText(pending, Json.Serialize(new { product = Product }), Encoding.UTF8);
string cleanup = Path.Combine(Path.GetTempPath(), "YouTubeDL-Cleanup-" + Guid.NewGuid().ToString("N") + ".exe");
// Do not carry EFS attributes from AppData into the browser's temp
// directory; that directory may reside on a different volume.
File.WriteAllBytes(cleanup, File.ReadAllBytes(manager));
Process.Start(new ProcessStartInfo(cleanup, "--finish-uninstall --silent --target-root \"" + root + "\" --wait-pid " + Process.GetCurrentProcess().Id)
{ UseShellExecute = false, CreateNoWindow = true });
}
File.Delete(marker);
report(100, "Помощник удалён. Скачанные ролики сохранены.");
}
static void FinishRemoval(string root, Action<int, string> report)
{
string pending = Inside(root, "uninstall-pending.json");
if (!File.Exists(pending) || (string)ObjectFile(pending)["product"] != Product) return;
if (!File.Exists(Inside(root, "installation.json"))) File.Delete(Inside(root, "YouTubeDL-Manager.exe"));
File.Delete(pending); TryRemoveEmpty(root);
}
static void TryRemoveEmpty(string path)
{ try { if (Directory.Exists(path) && (File.GetAttributes(path) & FileAttributes.ReparsePoint) == 0) Directory.Delete(path, false); } catch (IOException) { } }
}
internal sealed class SetupWindow : Form
{
readonly Action<Action<int, string>> work;
readonly Label message;
readonly ProgressBar progress;
readonly Button close;
bool working = true;
public int ExitCode = 0;
public SetupWindow(Action<Action<int, string>> task, bool uninstall)
{
work = task; Text = "YouTubeDL"; Size = new Size(560, 285); StartPosition = FormStartPosition.CenterScreen;
FormBorderStyle = FormBorderStyle.FixedDialog; MaximizeBox = false; Font = new Font("Segoe UI", 10);
bool light = true;
using (var theme = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"))
light = theme == null || Convert.ToInt32(theme.GetValue("AppsUseLightTheme", 1)) != 0;
BackColor = light ? Color.FromArgb(248, 249, 251) : Color.FromArgb(22, 25, 31);
ForeColor = light ? Color.FromArgb(25, 30, 40) : Color.FromArgb(235, 241, 248);
var title = new Label { Text = uninstall ? "Удаление помощника" : "Установка помощника", Location = new Point(28, 24), AutoSize = true, Font = new Font("Segoe UI", 17, FontStyle.Bold) };
var subtitle = new Label { Text = "YouTubeDL · автономный комплект " + "@BUNDLE_VERSION@", Location = new Point(30, 65), AutoSize = true };
message = new Label { Text = "Подготавливаю…", Location = new Point(30, 105), Size = new Size(475, 47) };
progress = new ProgressBar { Location = new Point(30, 154), Size = new Size(480, 8), Style = ProgressBarStyle.Continuous };
close = new Button { Text = "Закрыть", Location = new Point(394, 189), Size = new Size(115, 33), Enabled = false, FlatStyle = FlatStyle.Flat };
close.Click += (sender, args) => Close(); Controls.AddRange(new Control[] { title, subtitle, message, progress, close });
FormClosing += (sender, args) => { if (working) args.Cancel = true; };
Shown += async (sender, args) =>
{
try
{
await Task.Run(() => work((percent, text) => BeginInvoke(new Action(() => { progress.Value = Math.Max(0, Math.Min(100, percent)); message.Text = text; }))));
}
catch (Exception error) { ExitCode = 1; message.Text = error.Message; }
finally { working = false; close.Enabled = true; close.Focus(); }
};
}
}
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="YouTubeDL.Setup"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"><security><requestedPrivileges><requestedExecutionLevel level="asInvoker" uiAccess="false"/></requestedPrivileges></security></trustInfo>
<application xmlns="urn:schemas-microsoft-com:asm.v3"><windowsSettings><longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware><dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware></windowsSettings></application>
<dependency><dependentAssembly><assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/></dependentAssembly></dependency>
</assembly>