commit 81b806cfed42544d1f9ca769402815351c09dc1a Author: kirieska Date: Mon Sep 14 20:24:13 2026 +0200 сырой проект diff --git a/App.axaml b/App.axaml new file mode 100644 index 0000000..4851025 --- /dev/null +++ b/App.axaml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/App.axaml.cs b/App.axaml.cs new file mode 100644 index 0000000..678a716 --- /dev/null +++ b/App.axaml.cs @@ -0,0 +1,28 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using SpotXInstaller.ViewModels; +using SpotXInstaller.Views; + +namespace SpotXInstaller; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = new MainViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/Assets/avalonia-logo.ico b/Assets/avalonia-logo.ico new file mode 100644 index 0000000..ee7233c Binary files /dev/null and b/Assets/avalonia-logo.ico differ diff --git a/Models/InstallOptions.cs b/Models/InstallOptions.cs new file mode 100644 index 0000000..1c3ab4b --- /dev/null +++ b/Models/InstallOptions.cs @@ -0,0 +1,7 @@ +namespace SpotXInstaller.Models; + +public sealed record InstallOptions( + bool BlockUpdates, + bool HidePodcasts, + bool HidePremium, + bool ClearCache); diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..46a431a --- /dev/null +++ b/Program.cs @@ -0,0 +1,24 @@ +using Avalonia; +using System; + +namespace SpotXInstaller; + +sealed class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() +#if DEBUG + .WithDeveloperTools() +#endif + .WithInterFont() + .LogToTrace(); +} diff --git a/Services/InstallerService.cs b/Services/InstallerService.cs new file mode 100644 index 0000000..7aeec7c --- /dev/null +++ b/Services/InstallerService.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using SpotXInstaller.Models; + +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"; + + public Task InstallAsync(InstallOptions o, + IProgress<(int Percent, string Status)> progress, + IProgress log, CancellationToken ct = default) + => RunAsync(o, isUninstall: false, progress, log, ct); + + 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 async Task RunAsync(InstallOptions o, bool isUninstall, + IProgress<(int, string)> progress, IProgress log, CancellationToken ct) + { + 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 { } + } + } + + private static Task ExecAsync(string file, string[] argv, + 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 (var a in argv) psi.ArgumentList.Add(a); + + 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(); } + }; + + using var reg = ct.Register(() => + { + try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } + catch { } + }); + + proc.Start(); + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + return tcs.Task; + } +} diff --git a/SpotXInstaller.csproj b/SpotXInstaller.csproj new file mode 100644 index 0000000..a59bc8c --- /dev/null +++ b/SpotXInstaller.csproj @@ -0,0 +1,25 @@ + + + WinExe + net10.0 + enable + app.manifest + + + + + + + + + + + + + + None + All + + + + diff --git a/ViewLocator.cs b/ViewLocator.cs new file mode 100644 index 0000000..ea1f18c --- /dev/null +++ b/ViewLocator.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using SpotXInstaller.ViewModels; + +namespace SpotXInstaller; + +/// +/// Given a view model, returns the corresponding view if possible. +/// +[RequiresUnreferencedCode( + "Default implementation of ViewLocator involves reflection which may be trimmed away.", + Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] +public class ViewLocator : IDataTemplate +{ + public Control? Build(object? param) + { + if (param is null) + return null; + + var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + + return new TextBlock { Text = "Not Found: " + name }; + } + + public bool Match(object? data) + { + return data is ViewModelBase; + } +} diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..dc25eb3 --- /dev/null +++ b/ViewModels/MainViewModel.cs @@ -0,0 +1,129 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SpotXInstaller.Models; +using SpotXInstaller.Services; + +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; + + // --- POPUP --- + [ObservableProperty] private bool _showPopup; + [ObservableProperty] private string _popupTitle = ""; + [ObservableProperty] private string _popupMessage = ""; + [ObservableProperty] private string _popupColor = "#8B5CF6"; + + // --- ГАЛОЧКИ --- + [ObservableProperty] private bool _blockUpdates = true; + [ObservableProperty] private bool _hidePodcasts = true; + [ObservableProperty] private bool _hidePremium = true; + [ObservableProperty] private bool _clearCache; + + // --- ПРОГРЕСС --- + [ObservableProperty] private double _progressValue; + [ObservableProperty] private string _progressStatus = "Подготовка..."; + [ObservableProperty] private string _miniLog = ""; + + public MainViewModel() => _ = BootAsync(); + + 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) + { + IsStepLoading = loading; + IsStepHome = home; + IsStepSettings = settings; + IsStepProgress = progress; + } + + [RelayCommand] private void GoToSettings() => SwitchTo(settings: true); + [RelayCommand] private void GoBackToHome() => SwitchTo(home: true); + [RelayCommand] private void ClosePopup() => ShowPopup = false; + + [RelayCommand] + private async Task StartInstallAsync() + { + await RunInstallerAsync(isUninstall: false); + } + + [RelayCommand] + private async Task StartUninstallAsync() + { + await RunInstallerAsync(isUninstall: true); + } + + private async Task RunInstallerAsync(bool isUninstall) + { + ProgressValue = 0; + MiniLog = ""; + 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); + + try + { + int code = isUninstall + ? await _installer.UninstallAsync(progress, log, _cts.Token) + : await _installer.InstallAsync( + new InstallOptions(BlockUpdates, HidePodcasts, HidePremium, ClearCache), + progress, log, _cts.Token); + + SwitchTo(home: true); + + if (code == 0) + ShowNotification( + isUninstall ? "ГОТОВО" : "УСПЕХ", + isUninstall + ? "Оригинальный клиент Spotify восстановлен." + : "SpotX успешно установлен.", + isUninstall ? "#8B5CF6" : "#00E676"); + else + ShowNotification("ОШИБКА", + $"Скрипт завершился с кодом {code}.\n\n{MiniLog[..Math.Min(200, MiniLog.Length)]}", + "#FF3B30"); + } + catch (OperationCanceledException) + { + SwitchTo(home: true); + ShowNotification("ОТМЕНЕНО", "Операция прервана.", "#FFB300"); + } + catch (Exception ex) + { + SwitchTo(home: true); + ShowNotification("ОШИБКА", ex.Message, "#FF3B30"); + } + } + + private void ShowNotification(string title, string message, string hexColor) + { + PopupTitle = title; + PopupMessage = message; + PopupColor = hexColor; + ShowPopup = true; + } +} diff --git a/ViewModels/ViewModelBase.cs b/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..92c0e08 --- /dev/null +++ b/ViewModels/ViewModelBase.cs @@ -0,0 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SpotXInstaller.ViewModels; + +public abstract class ViewModelBase : ObservableObject +{ +} diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml new file mode 100644 index 0000000..5dd0a28 --- /dev/null +++ b/Views/MainWindow.axaml @@ -0,0 +1,312 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +