Publish YouTubeDL 1.4.5 and website on Gitea
Check extension and helper / check (push) Canceled after 0s
Check extension and helper / check (push) Canceled after 0s
This commit is contained in:
commit
5f2aad00f4
97 files changed
+8451
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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(); }
|
||||
@@ -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()
|
||||
@@ -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
@@ -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(); }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user