// 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(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> 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 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 ObjectFile(string path) { return Json.Deserialize>(File.ReadAllText(path, Encoding.UTF8)); } #if SETUP static void Install(string root, bool register, Action 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 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 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 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(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 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> work; readonly Label message; readonly ProgressBar progress; readonly Button close; bool working = true; public int ExitCode = 0; public SetupWindow(Action> 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(); } }; } }