Files
YouTubeDL/tools/setup.cs
T
DepressedCat 5f2aad00f4
Check extension and helper / check (push) Canceled after 0s
Publish YouTubeDL 1.4.5 and website on Gitea
2026-10-10 23:53:17 +03:00

413 lines
26 KiB
C#

// 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(); }
};
}
}