diff --git a/README.md b/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/Services/InstallerService.cs b/Services/InstallerService.cs index 7aeec7c..2cd2a6e 100644 --- a/Services/InstallerService.cs +++ b/Services/InstallerService.cs @@ -11,149 +11,378 @@ namespace SpotXInstaller.Services; public sealed class InstallerService { - private const string BashUrl = "https://api.kirieska.xyz/files/SPOTX/spotx.sh"; - private const string PsUrl = "https://api.kirieska.xyz/files/SPOTX/run.ps1"; - private const string PsUninstallUrl = "https://api.kirieska.xyz/files/SPOTX/uninstall.ps1"; + private const string BashUrl = + "https://api.kirieska.xyz/files/SPOTX/spotx.sh"; - public Task InstallAsync(InstallOptions o, - IProgress<(int Percent, string Status)> progress, - IProgress log, CancellationToken ct = default) - => RunAsync(o, isUninstall: false, progress, log, ct); + private const string PsUrl = + "https://api.kirieska.xyz/files/SPOTX/run.ps1"; - public Task UninstallAsync( - IProgress<(int Percent, string Status)> progress, - IProgress log, CancellationToken ct = default) - => RunAsync(new InstallOptions(false, false, false, false), - isUninstall: true, progress, log, ct); + private const string PsUninstallUrl = + "https://api.kirieska.xyz/files/SPOTX/uninstall.ps1"; - private async Task RunAsync(InstallOptions o, bool isUninstall, - IProgress<(int, string)> progress, IProgress log, CancellationToken ct) + public Task InstallAsync( + InstallOptions options, + IProgress<(int Percent, string Status)> progress, + IProgress log, + CancellationToken ct = default) + { + return RunAsync( + options, + isUninstall: false, + progress, + log, + ct); + } + + public Task UninstallAsync( + IProgress<(int Percent, string Status)> progress, + IProgress log, + CancellationToken ct = default) + { + return RunAsync( + new InstallOptions(false, false, false, false), + isUninstall: true, + progress, + log, + ct); + } + + private async Task RunAsync( + InstallOptions options, + bool isUninstall, + IProgress<(int Percent, string Status)> progress, + IProgress log, + CancellationToken ct) + { + bool isWindows = OperatingSystem.IsWindows(); + + string url = isUninstall + ? (isWindows ? PsUninstallUrl : BashUrl) + : (isWindows ? PsUrl : BashUrl); + + string extension = isWindows ? ".ps1" : ".sh"; + + string scriptPath = Path.Combine( + Path.GetTempPath(), + $"spotx_{Guid.NewGuid():N}{extension}"); + + try + { + ct.ThrowIfCancellationRequested(); + + // --------------------------------------------------------- + // 1. DOWNLOAD + // --------------------------------------------------------- + + progress.Report((5, "Загрузка скрипта...")); + + using (var http = new HttpClient + { + Timeout = TimeSpan.FromSeconds(60) + }) + { + http.DefaultRequestHeaders.UserAgent.ParseAdd( + "SpotXInstaller/1.0"); + + byte[] data = await http.GetByteArrayAsync( + url, + ct); + + await File.WriteAllBytesAsync( + scriptPath, + data, + ct); + } + + progress.Report((20, "Скрипт загружен")); + + ct.ThrowIfCancellationRequested(); + + // --------------------------------------------------------- + // 2. UNIX PERMISSIONS + // --------------------------------------------------------- + + if (!isWindows) + { + progress.Report( + (24, "Подготовка скрипта...")); + + int chmodExit = await ExecAsync( + "/bin/chmod", + new[] + { + "+x", + scriptPath + }, + _ => { }, + ct); + + if (chmodExit != 0) { - bool isWin = OperatingSystem.IsWindows(); - string url = isUninstall - ? (isWin ? PsUninstallUrl : BashUrl) - : (isWin ? PsUrl : BashUrl); - string ext = isWin ? ".ps1" : ".sh"; - string scriptPath = Path.Combine(Path.GetTempPath(), $"spotx_{Guid.NewGuid():N}{ext}"); - - try - { - // === ШАГ 1 (0–20%): скачивание === - progress.Report((3, "Загрузка скрипта...")); - using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }) - { - http.DefaultRequestHeaders.UserAgent.ParseAdd("SpotXInstaller/1.0"); - byte[] data = await http.GetByteArrayAsync(url, ct).ConfigureAwait(false); - await File.WriteAllBytesAsync(scriptPath, data, ct).ConfigureAwait(false); - } - progress.Report((20, "Скрипт загружен")); - - if (!isWin) - { - progress.Report((25, "Установка прав на исполнение...")); - await ExecAsync("/bin/chmod", new[] { "+x", scriptPath }, _ => { }, ct) - .ConfigureAwait(false); - } - progress.Report((30, isUninstall ? "Восстановление..." : "Запуск установки...")); - - // === ШАГ 2: маппинг флагов === - string file; - var argv = new List(); - - if (isWin) - { - file = "powershell.exe"; - argv.AddRange(new[] - { - "-NoProfile", "-ExecutionPolicy", "Bypass", - "-File", scriptPath - }); - - if (isUninstall) - { - // uninstall.ps1 не принимает флагов — просто отрабатывает - } - else - { - if (o.BlockUpdates) argv.Add("-block_update_on"); - if (o.HidePodcasts) argv.Add("-podcasts_off"); - if (o.HidePremium) argv.Add("-adsections_off"); - argv.Add("-no_pause"); // не ждать Enter в конце - } - } - else - { - file = "/bin/bash"; - argv.Add(scriptPath); - - if (isUninstall) - { - argv.Add("--uninstall"); - } - else - { - if (o.BlockUpdates) argv.Add("-B"); - if (o.HidePodcasts) argv.Add("-h"); - if (o.HidePremium) argv.Add("-e"); - if (o.ClearCache) argv.Add("-c"); - } - } - - // === ШАГ 3 (30–95%): запуск + построчный стрим === - int cur = 30; - var gate = new object(); - - int exit = await ExecAsync(file, argv.ToArray(), line => - { - log.Report(line); - lock (gate) - { - cur = Math.Min(cur + 2, 95); - progress.Report((cur, isUninstall ? "Восстановление..." : "Установка...")); - } - }, ct).ConfigureAwait(false); - - progress.Report((100, "Готово")); - return exit; - } - finally - { - try { if (File.Exists(scriptPath)) File.Delete(scriptPath); } catch { } - } + throw new InvalidOperationException( + $"Не удалось установить права на скрипт. Код: {chmodExit}"); } + } - private static Task ExecAsync(string file, string[] argv, - Action onLine, CancellationToken ct) + ct.ThrowIfCancellationRequested(); + + // --------------------------------------------------------- + // 3. BUILD COMMAND + // --------------------------------------------------------- + + string file; + var arguments = new List(); + + if (isWindows) + { + file = "powershell.exe"; + + arguments.Add("-NoProfile"); + arguments.Add("-ExecutionPolicy"); + arguments.Add("Bypass"); + arguments.Add("-File"); + arguments.Add(scriptPath); + + if (!isUninstall) { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var psi = new ProcessStartInfo - { - FileName = file, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - foreach (var a in argv) psi.ArgumentList.Add(a); + if (options.BlockUpdates) + arguments.Add("-block_update_on"); - var proc = new Process { StartInfo = psi, EnableRaisingEvents = true }; - proc.OutputDataReceived += (_, e) => { if (e.Data is { } s) onLine(s); }; - proc.ErrorDataReceived += (_, e) => { if (e.Data is { } s) onLine(s); }; - proc.Exited += (_, _) => - { - try { tcs.TrySetResult(proc.ExitCode); } - finally { proc.Dispose(); } - }; + if (options.HidePodcasts) + arguments.Add("-podcasts_off"); - using var reg = ct.Register(() => - { - try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } - catch { } - }); + if (options.HidePremium) + arguments.Add("-adsections_off"); - proc.Start(); - proc.BeginOutputReadLine(); - proc.BeginErrorReadLine(); - return tcs.Task; + arguments.Add("-no_pause"); } + } + else + { + file = "/bin/bash"; + + arguments.Add(scriptPath); + + if (isUninstall) + { + arguments.Add("--uninstall"); + } + else + { + if (options.BlockUpdates) + arguments.Add("-B"); + + if (options.HidePodcasts) + arguments.Add("-h"); + + if (options.HidePremium) + arguments.Add("-e"); + + if (options.ClearCache) + arguments.Add("-c"); + } + } + + // --------------------------------------------------------- + // 4. RUN + // --------------------------------------------------------- + + progress.Report(( + 30, + isUninstall + ? "Запуск восстановления..." + : "Запуск установки...")); + + int exitCode = await ExecAsync( + file, + arguments.ToArray(), + line => + { + if (string.IsNullOrWhiteSpace(line)) + return; + + log.Report(line); + + string lower = line.ToLowerInvariant(); + + // Никакого фальшивого +2%. + // Только изменение текста статуса по реальному выводу. + if (lower.Contains("download") || + lower.Contains("скач")) + { + progress.Report( + (40, "Загрузка Spotify...")); + } + else if (lower.Contains("install") || + lower.Contains("установ")) + { + progress.Report( + (60, "Установка Spotify...")); + } + else if (lower.Contains("patch") || + lower.Contains("патч")) + { + progress.Report( + (80, "Применение SpotX...")); + } + else if (lower.Contains("complete") || + lower.Contains("готов")) + { + progress.Report( + (90, "Завершение...")); + } + }, + ct); + + ct.ThrowIfCancellationRequested(); + + // --------------------------------------------------------- + // 5. RESULT + // --------------------------------------------------------- + + if (exitCode == 0) + { + progress.Report( + (100, + isUninstall + ? "Восстановление завершено" + : "Установка завершена")); + } + else + { + progress.Report( + (100, + $"Ошибка установки (код {exitCode})")); + } + + return exitCode; + } + finally + { + try + { + if (File.Exists(scriptPath)) + File.Delete(scriptPath); + } + catch + { + // Игнорируем ошибку очистки временного файла. + } + } + } + + private static async Task ExecAsync( + string file, + string[] arguments, + Action onLine, + CancellationToken ct) + { + var tcs = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var psi = new ProcessStartInfo + { + FileName = file, + + RedirectStandardOutput = true, + RedirectStandardError = true, + + UseShellExecute = false, + CreateNoWindow = true + }; + + foreach (string argument in arguments) + psi.ArgumentList.Add(argument); + + var process = new Process + { + StartInfo = psi, + EnableRaisingEvents = true + }; + + int finished = 0; + + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + onLine(e.Data); + }; + + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + onLine(e.Data); + }; + + process.Exited += (_, _) => + { + if (Interlocked.Exchange(ref finished, 1) != 0) + return; + + try + { + tcs.TrySetResult(process.ExitCode); + } + catch + { + tcs.TrySetResult(-1); + } + finally + { + process.Dispose(); + } + }; + + using CancellationTokenRegistration registration = + ct.Register(() => + { + if (Interlocked.Exchange(ref finished, 1) != 0) + return; + + try + { + if (!process.HasExited) + { + process.Kill( + entireProcessTree: true); + } + } + catch + { + // Процесс уже мог завершиться. + } + + tcs.TrySetCanceled(ct); + }); + + try + { + ct.ThrowIfCancellationRequested(); + + if (!process.Start()) + throw new InvalidOperationException( + $"Не удалось запустить процесс: {file}"); + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + return await tcs.Task.ConfigureAwait(false); + } + catch + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + // Ignore. + } + + throw; + } + } } + diff --git a/SpotXInstaller.csproj b/SpotXInstaller.csproj index a59bc8c..9105b95 100644 --- a/SpotXInstaller.csproj +++ b/SpotXInstaller.csproj @@ -1,9 +1,18 @@  + - WinExe + Exe net10.0 + enable + enable + app.manifest + + SpotXInstaller + SpotXInstaller + + false @@ -16,10 +25,39 @@ + + + + + None All + + + + + true + true + true + + + + + true + true + true + + + + + true + true + true + + + diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index dc25eb3..5cb04f7 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -11,40 +11,102 @@ namespace SpotXInstaller.ViewModels; public partial class MainViewModel : ViewModelBase { private readonly InstallerService _installer = new(); + private CancellationTokenSource? _cts; - // --- СОСТОЯНИЯ ЭКРАНОВ --- - [ObservableProperty] private bool _isStepLoading = true; - [ObservableProperty] private bool _isStepHome; - [ObservableProperty] private bool _isStepSettings; - [ObservableProperty] private bool _isStepProgress; + // --------------------------------------------------------- + // SCREEN STATES + // --------------------------------------------------------- - // --- POPUP --- - [ObservableProperty] private bool _showPopup; - [ObservableProperty] private string _popupTitle = ""; - [ObservableProperty] private string _popupMessage = ""; - [ObservableProperty] private string _popupColor = "#8B5CF6"; + [ObservableProperty] + private bool _isStepLoading = true; - // --- ГАЛОЧКИ --- - [ObservableProperty] private bool _blockUpdates = true; - [ObservableProperty] private bool _hidePodcasts = true; - [ObservableProperty] private bool _hidePremium = true; - [ObservableProperty] private bool _clearCache; + [ObservableProperty] + private bool _isStepHome; - // --- ПРОГРЕСС --- - [ObservableProperty] private double _progressValue; - [ObservableProperty] private string _progressStatus = "Подготовка..."; - [ObservableProperty] private string _miniLog = ""; + [ObservableProperty] + private bool _isStepSettings; - public MainViewModel() => _ = BootAsync(); + [ObservableProperty] + private bool _isStepProgress; + + // --------------------------------------------------------- + // POPUP + // --------------------------------------------------------- + + [ObservableProperty] + private bool _showPopup; + + [ObservableProperty] + private string _popupTitle = ""; + + [ObservableProperty] + private string _popupMessage = ""; + + [ObservableProperty] + private string _popupColor = "#8B5CF6"; + + // --------------------------------------------------------- + // SETTINGS + // --------------------------------------------------------- + + [ObservableProperty] + private bool _blockUpdates = true; + + [ObservableProperty] + private bool _hidePodcasts = true; + + [ObservableProperty] + private bool _hidePremium = true; + + [ObservableProperty] + private bool _clearCache; + + // --------------------------------------------------------- + // PROGRESS + // --------------------------------------------------------- + + [ObservableProperty] + private double _progressValue; + + [ObservableProperty] + private string _progressStatus = "Подготовка..."; + + [ObservableProperty] + private string _miniLog = ""; + + // --------------------------------------------------------- + // STATE + // --------------------------------------------------------- + + [ObservableProperty] + private bool _isInstalling; + + public MainViewModel() + { + _ = BootAsync(); + } + + // --------------------------------------------------------- + // BOOT + // --------------------------------------------------------- private async Task BootAsync() { await Task.Delay(3000); + SwitchTo(home: true); } - private void SwitchTo(bool loading = false, bool home = false, bool settings = false, bool progress = false) + // --------------------------------------------------------- + // NAVIGATION + // --------------------------------------------------------- + + private void SwitchTo( + bool loading = false, + bool home = false, + bool settings = false, + bool progress = false) { IsStepLoading = loading; IsStepHome = home; @@ -52,9 +114,33 @@ public partial class MainViewModel : ViewModelBase IsStepProgress = progress; } - [RelayCommand] private void GoToSettings() => SwitchTo(settings: true); - [RelayCommand] private void GoBackToHome() => SwitchTo(home: true); - [RelayCommand] private void ClosePopup() => ShowPopup = false; + [RelayCommand] + private void GoToSettings() + { + if (IsInstalling) + return; + + SwitchTo(settings: true); + } + + [RelayCommand] + private void GoBackToHome() + { + if (IsInstalling) + return; + + SwitchTo(home: true); + } + + [RelayCommand] + private void ClosePopup() + { + ShowPopup = false; + } + + // --------------------------------------------------------- + // INSTALL + // --------------------------------------------------------- [RelayCommand] private async Task StartInstallAsync() @@ -62,68 +148,228 @@ public partial class MainViewModel : ViewModelBase await RunInstallerAsync(isUninstall: false); } + // --------------------------------------------------------- + // UNINSTALL + // --------------------------------------------------------- + [RelayCommand] private async Task StartUninstallAsync() { await RunInstallerAsync(isUninstall: true); } + // --------------------------------------------------------- + // CANCEL BUTTON + // --------------------------------------------------------- + + [RelayCommand] + private async Task CancelInstallAsync() + { + if (!IsInstalling) + return; + + ProgressStatus = "Отмена установки..."; + + try + { + _cts?.Cancel(); + } + catch + { + // Ignore. + } + + await Task.CompletedTask; + } + + // --------------------------------------------------------- + // WINDOW CLOSE + // --------------------------------------------------------- + + public async Task CancelInstallationAsync() + { + if (!IsInstalling) + return; + + try + { + ProgressStatus = "Завершение процессов..."; + + _cts?.Cancel(); + + // Даём process tree время корректно завершиться. + await Task.Delay(150); + } + catch + { + // Ignore. + } + } + + // --------------------------------------------------------- + // MAIN INSTALLER + // --------------------------------------------------------- + private async Task RunInstallerAsync(bool isUninstall) { + if (IsInstalling) + return; + ProgressValue = 0; MiniLog = ""; - ProgressStatus = isUninstall ? "Подготовка восстановления..." : "Подготовка установки..."; + + ProgressStatus = isUninstall + ? "Подготовка восстановления..." + : "Подготовка установки..."; + SwitchTo(progress: true); _cts?.Dispose(); _cts = new CancellationTokenSource(); - var progress = new Progress<(int Percent, string Status)>(p => - { - ProgressValue = p.Percent; - ProgressStatus = p.Status; - }); - var log = new Progress(l => MiniLog = l); + IsInstalling = true; + + var progress = + new Progress<(int Percent, string Status)>( + p => + { + ProgressValue = p.Percent; + ProgressStatus = p.Status; + }); + + var log = + new Progress( + line => + { + MiniLog = line; + }); try { - int code = isUninstall - ? await _installer.UninstallAsync(progress, log, _cts.Token) - : await _installer.InstallAsync( - new InstallOptions(BlockUpdates, HidePodcasts, HidePremium, ClearCache), - progress, log, _cts.Token); + int code; + + if (isUninstall) + { + code = await _installer.UninstallAsync( + progress, + log, + _cts.Token); + } + else + { + code = await _installer.InstallAsync( + new InstallOptions( + BlockUpdates, + HidePodcasts, + HidePremium, + ClearCache), + progress, + log, + _cts.Token); + } + + IsInstalling = false; SwitchTo(home: true); if (code == 0) + { ShowNotification( - isUninstall ? "ГОТОВО" : "УСПЕХ", + isUninstall + ? "ГОТОВО" + : "УСПЕХ", + isUninstall ? "Оригинальный клиент Spotify восстановлен." : "SpotX успешно установлен.", - isUninstall ? "#8B5CF6" : "#00E676"); - else - ShowNotification("ОШИБКА", - $"Скрипт завершился с кодом {code}.\n\n{MiniLog[..Math.Min(200, MiniLog.Length)]}", - "#FF3B30"); + + isUninstall + ? "#8B5CF6" + : "#00E676"); + } + else + { + string errorText = + MiniLog.Length > 500 + ? MiniLog[..500] + : MiniLog; + + ShowNotification( + "ОШИБКА", + $"Скрипт завершился с кодом {code}.\n\n{errorText}", + "#FF3B30"); + } } catch (OperationCanceledException) { + IsInstalling = false; + SwitchTo(home: true); - ShowNotification("ОТМЕНЕНО", "Операция прервана.", "#FFB300"); + + ShowNotification( + "ОТМЕНЕНО", + "Установка была отменена.", + "#FFB300"); } catch (Exception ex) { + IsInstalling = false; + SwitchTo(home: true); - ShowNotification("ОШИБКА", ex.Message, "#FF3B30"); + + ShowNotification( + "ОШИБКА", + ex.Message, + "#FF3B30"); + } + finally + { + IsInstalling = false; + + _cts?.Dispose(); + _cts = null; } } - private void ShowNotification(string title, string message, string hexColor) + // --------------------------------------------------------- + // POPUP + // --------------------------------------------------------- + + private void ShowNotification( + string title, + string message, + string hexColor) { PopupTitle = title; PopupMessage = message; PopupColor = hexColor; ShowPopup = true; } + + // --------------------------------------------------------- + // DISPOSE + // --------------------------------------------------------- + + public async Task DisposeAsync() + { + if (!IsInstalling) + return; + + try + { + _cts?.Cancel(); + + await Task.Delay(150); + } + catch + { + // Ignore. + } + + _cts?.Dispose(); + _cts = null; + + IsInstalling = false; + } } + diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs index 058ab80..62b16eb 100644 --- a/Views/MainWindow.axaml.cs +++ b/Views/MainWindow.axaml.cs @@ -1,11 +1,43 @@ +using System; using Avalonia.Controls; +using Avalonia.Interactivity; +using SpotXInstaller.ViewModels; namespace SpotXInstaller.Views; public partial class MainWindow : Window { + private bool _allowClose; + public MainWindow() { InitializeComponent(); + + Closing += MainWindow_Closing; } -} \ No newline at end of file + + private async void MainWindow_Closing( + object? sender, + WindowClosingEventArgs e) + { + if (_allowClose) + return; + + if (DataContext is not MainViewModel vm) + return; + + if (!vm.IsInstalling) + return; + + // Не закрываем окно мгновенно. + // Сначала отменяем установку и убиваем process tree. + e.Cancel = true; + + await vm.CancelInstallationAsync(); + + _allowClose = true; + + Close(); + } +} +