diff --git a/MetaforceInstaller.Cli/MetaforceInstaller.Cli.csproj b/MetaforceInstaller.Cli/MetaforceInstaller.Cli.csproj
new file mode 100644
index 0000000..a267acc
--- /dev/null
+++ b/MetaforceInstaller.Cli/MetaforceInstaller.Cli.csproj
@@ -0,0 +1,24 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ true
+ true
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MetaforceInstaller.Cli/Program.cs b/MetaforceInstaller.Cli/Program.cs
new file mode 100644
index 0000000..bf8bf51
--- /dev/null
+++ b/MetaforceInstaller.Cli/Program.cs
@@ -0,0 +1,133 @@
+using MetaforceInstaller.Cli.Utils;
+using MetaforceInstaller.Core.Services;
+
+namespace MetaforceInstaller.Cli;
+
+static class Program
+{
+ static async Task Main(string[] args)
+ {
+ try
+ {
+ var installationRequest = ArgumentParser.ParseArguments(args);
+
+ if (installationRequest is null ||
+ string.IsNullOrEmpty(installationRequest.ApkPath) ||
+ string.IsNullOrEmpty(installationRequest.ZipPath))
+ {
+ ShowUsage();
+ return;
+ }
+
+ var adbService = new AdbService();
+
+ var apkInfo = ApkScrapper.GetApkInfo(installationRequest.ApkPath);
+ var zipName = Path.GetFileName(installationRequest.ZipPath);
+ var outputPath =
+ @$"/storage/emulated/0/Android/data/{apkInfo.PackageName}/files/{zipName}";
+
+ // Подписка на события прогресса
+ adbService.ProgressChanged += OnProgressChanged;
+ adbService.StatusChanged += OnStatusChanged;
+
+ // Получение информации об устройстве
+ var deviceInfo = adbService.GetDeviceInfo();
+ Console.WriteLine($"Найдено устройство: {deviceInfo.SerialNumber}");
+ Console.WriteLine($"Состояние: {deviceInfo.State}");
+ Console.WriteLine($"Модель: {deviceInfo.Model} - {deviceInfo.Name}");
+ Console.WriteLine();
+
+ // Создание объекта для отслеживания прогресса
+ var progress = new Progress(OnProgressReport);
+
+ // Установка APK
+ await adbService.InstallApkAsync(installationRequest.ApkPath, progress);
+ Console.WriteLine();
+
+ // Копирование файла
+ await adbService.CopyFileAsync(installationRequest.ZipPath, outputPath, progress);
+ Console.WriteLine();
+
+ Console.WriteLine("Операция завершена успешно!");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Ошибка: {ex.Message}");
+ }
+ }
+
+ private static void OnProgressChanged(object? sender, MetaforceInstaller.Core.Models.ProgressInfo e)
+ {
+ DrawProgressBar(e.PercentageComplete, e.BytesTransferred, e.TotalBytes);
+ }
+
+ private static void OnStatusChanged(object? sender, string e)
+ {
+ Console.WriteLine(e);
+ }
+
+ private static void OnProgressReport(MetaforceInstaller.Core.Models.ProgressInfo progressInfo)
+ {
+ if (progressInfo.TotalBytes > 0)
+ {
+ DrawProgressBar(progressInfo.PercentageComplete, progressInfo.BytesTransferred, progressInfo.TotalBytes);
+ }
+ else
+ {
+ // Для случаев без информации о байтах (например, установка APK)
+ DrawProgressBar(progressInfo.PercentageComplete, 0, 100);
+ }
+ }
+
+ static void ShowUsage()
+ {
+ Console.WriteLine("Использование:");
+ Console.WriteLine(
+ " MetaforceInstaller.exe --apk <путь_к_apk> --content <путь_к_zip> --output <путь_для контента>");
+ Console.WriteLine(" MetaforceInstaller.exe -a <путь_к_apk> -c <путь_к_zip> -o <путь_для_контента>");
+ Console.WriteLine();
+ Console.WriteLine("Параметры:");
+ Console.WriteLine(" --apk, -a Путь к APK файлу");
+ Console.WriteLine(" --content, -c Путь к ZIP файлу с контентом");
+ Console.WriteLine(" --output, -o Путь для копирования контента");
+ Console.WriteLine(" --help, -h Показать эту справку");
+ Console.WriteLine();
+ Console.WriteLine("Пример:");
+ Console.WriteLine(
+ " MetaforceInstaller.exe --apk \"C:\\app.apk\" --content \"C:\\data.zip\" --output \"/sdcard/data.zip\"");
+ Console.WriteLine(" MetaforceInstaller.exe -a app.apk -c data.zip -o /sdcard/data.zip");
+ }
+
+ private static void DrawProgressBar(int progress, long receivedBytes, long totalBytes)
+ {
+ Console.SetCursorPosition(0, Console.CursorTop);
+
+ var barLength = 40;
+ var filledLength = (int)(barLength * progress / 100.0);
+
+ var bar = "[" + new string('█', filledLength) + new string('░', barLength - filledLength) + "]";
+
+ string bytesText = "";
+ if (totalBytes > 0)
+ {
+ bytesText = $" {FormatBytes(receivedBytes)} / {FormatBytes(totalBytes)}";
+ }
+
+ Console.Write($"\r{bar} {progress}%{bytesText}");
+ }
+
+ private static string FormatBytes(long bytes)
+ {
+ string[] suffixes = ["B", "KB", "MB", "GB", "TB"];
+ var counter = 0;
+ double number = bytes;
+
+ while (Math.Round(number / 1024) >= 1)
+ {
+ number /= 1024;
+ counter++;
+ }
+
+ return $"{number:N1} {suffixes[counter]}";
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Cli/Utils/ArgumentParser.cs b/MetaforceInstaller.Cli/Utils/ArgumentParser.cs
new file mode 100644
index 0000000..ffdb561
--- /dev/null
+++ b/MetaforceInstaller.Cli/Utils/ArgumentParser.cs
@@ -0,0 +1,53 @@
+using MetaforceInstaller.Core.Models;
+
+namespace MetaforceInstaller.Cli.Utils;
+
+public static class ArgumentParser
+{
+ public static InstallationRequest? ParseArguments(string[] args)
+ {
+ var result = new InstallationRequest();
+
+ for (var i = 0; i < args.Length; i++)
+ {
+ switch (args[i].ToLower())
+ {
+ case "--apk":
+ case "-a":
+ if (i + 1 < args.Length)
+ {
+ result.ApkPath = args[i + 1];
+ i++;
+ }
+
+ break;
+
+ case "--content":
+ case "-c":
+ if (i + 1 < args.Length)
+ {
+ result.ZipPath = args[i + 1];
+ i++;
+ }
+
+ break;
+
+ case "--output":
+ case "-o":
+ if (i + 1 < args.Length)
+ {
+ result.OutputPath = args[i + 1];
+ i++;
+ }
+
+ break;
+
+ case "--help":
+ case "-h":
+ return null;
+ }
+ }
+
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Cloud/ICloudService.cs b/MetaforceInstaller.Cloud/ICloudService.cs
deleted file mode 100644
index a179bba..0000000
--- a/MetaforceInstaller.Cloud/ICloudService.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using MetaforceInstaller.Cloud.Models;
-
-namespace MetaforceInstaller.Cloud;
-
-public interface ICloudService
-{
- Task> GetObjects(string webLink);
-}
\ No newline at end of file
diff --git a/MetaforceInstaller.Cloud/Implementations/MailRu/DTO/CloudObjectResponse.cs b/MetaforceInstaller.Cloud/Implementations/MailRu/DTO/CloudObjectResponse.cs
deleted file mode 100644
index 7f2bdb3..0000000
--- a/MetaforceInstaller.Cloud/Implementations/MailRu/DTO/CloudObjectResponse.cs
+++ /dev/null
@@ -1,181 +0,0 @@
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace MetaforceInstaller.Cloud.Implementations.MailRu.DTO;
-
-public sealed class CloudObjectsResponse
-{
- [JsonPropertyName("count")]
- public CountInfo? Count { get; init; }
-
- [JsonPropertyName("name")]
- public string? Name { get; init; }
-
- [JsonPropertyName("weblink")]
- public string? WebLink { get; init; }
-
- [JsonPropertyName("size")]
- public long? Size { get; init; }
-
- [JsonPropertyName("rev")]
- public long? Rev { get; init; }
-
- [JsonPropertyName("kind")]
- public string? Kind { get; init; }
-
- [JsonPropertyName("type")]
- public string? Type { get; init; }
-
- [JsonPropertyName("public")]
- public PublicInfo? Public { get; init; }
-
- [JsonPropertyName("list")]
- public List? List { get; init; }
-
- [JsonPropertyName("owner")]
- public OwnerInfo? Owner { get; init; }
-}
-
-public sealed class CountInfo
-{
- [JsonPropertyName("folders")]
- public int? Folders { get; init; }
-
- [JsonPropertyName("files")]
- public int? Files { get; init; }
-}
-
-public sealed class PublicInfo
-{
- [JsonPropertyName("type")]
- public string? Type { get; init; }
-
- [JsonPropertyName("name")]
- public string? Name { get; init; }
-
- [JsonPropertyName("id")]
- public string? Id { get; init; }
-
- [JsonPropertyName("ctime")]
- public long? CTime { get; init; }
-
- [JsonPropertyName("views")]
- public long? Views { get; init; }
-
- [JsonPropertyName("downloads")]
- public long? Downloads { get; init; }
-}
-
-public sealed class OwnerInfo
-{
- [JsonPropertyName("email")]
- public string? Email { get; init; }
-
- [JsonPropertyName("user_flags")]
- public UserFlags? UserFlags { get; init; }
-}
-
-public sealed class UserFlags
-{
- [JsonPropertyName("PAID_ACCOUNT")]
- public bool? PaidAccount { get; init; }
-}
-
-///
-/// Базовый тип элемента из "list". Конкретный тип выбирается по полю "type".
-///
-[JsonConverter(typeof(CloudObjectJsonConverter))]
-public abstract class CloudObjectBase
-{
- [JsonPropertyName("name")]
- public string? Name { get; init; }
-
- [JsonPropertyName("weblink")]
- public string? WebLink { get; init; }
-
- [JsonPropertyName("size")]
- public long? Size { get; init; }
-
- [JsonPropertyName("kind")]
- public string? Kind { get; init; }
-
- [JsonPropertyName("type")]
- public string? Type { get; init; }
-}
-
-public sealed class CloudFolderObject : CloudObjectBase
-{
- [JsonPropertyName("count")]
- public CountInfo? Count { get; init; }
-
- [JsonPropertyName("rev")]
- public long? Rev { get; init; }
-}
-
-public sealed class CloudFileObject : CloudObjectBase
-{
- [JsonPropertyName("mtime")]
- public long? MTime { get; init; }
-
- [JsonPropertyName("hash")]
- public string? Hash { get; init; }
-}
-
-public sealed class ZipWebLinkRequest
-{
- [JsonPropertyName("x-email")]
- public string XEmail { get; init; }
-
- [JsonPropertyName("weblink_list")]
- public IReadOnlyList WeblinkList { get; init; }
-
- [JsonPropertyName("name")]
- public string Name { get; init; }
-}
-
-public sealed class ZipWebLinkResponse
-{
- [JsonPropertyName("key")]
- public string Key { get; init; }
-}
-
-///
-/// Конвертер, который смотрит на поле "type" и десериализует в CloudFolderObject или CloudFileObject.
-///
-public sealed class CloudObjectJsonConverter : JsonConverter
-{
- public override CloudObjectBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- {
- using var doc = JsonDocument.ParseValue(ref reader);
-
- var root = doc.RootElement;
- if (!root.TryGetProperty("type", out var typeProp))
- throw new JsonException("Missing required property 'type' for cloud object.");
-
- var type = typeProp.GetString();
-
- return type switch
- {
- "folder" => root.Deserialize(options)
- ?? throw new JsonException("Failed to deserialize folder object."),
- "file" => root.Deserialize(options)
- ?? throw new JsonException("Failed to deserialize file object."),
- _ => throw new JsonException($"Unknown cloud object type '{type}'.")
- };
- }
-
- public override void Write(Utf8JsonWriter writer, CloudObjectBase value, JsonSerializerOptions options)
- {
- switch (value)
- {
- case CloudFolderObject folder:
- JsonSerializer.Serialize(writer, folder, options);
- break;
- case CloudFileObject file:
- JsonSerializer.Serialize(writer, file, options);
- break;
- default:
- throw new JsonException($"Unknown runtime type '{value.GetType().Name}'.");
- }
- }
-}
\ No newline at end of file
diff --git a/MetaforceInstaller.Cloud/Implementations/MailRu/MailRuCloudService.cs b/MetaforceInstaller.Cloud/Implementations/MailRu/MailRuCloudService.cs
deleted file mode 100644
index d7ae0ca..0000000
--- a/MetaforceInstaller.Cloud/Implementations/MailRu/MailRuCloudService.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-using System.Text;
-using System.Text.Json;
-using System.Web;
-using MetaforceInstaller.Cloud.Implementations.MailRu.DTO;
-using MetaforceInstaller.Cloud.Models;
-
-namespace MetaforceInstaller.Cloud.Implementations.MailRu;
-
-public class MailRuCloudService : ICloudService
-{
- private const string BaseUrl = "https://cloud.mail.ru";
-
- private static readonly JsonSerializerOptions JsonOptions = new()
- {
- PropertyNameCaseInsensitive = true
- };
-
- private readonly HttpClient _httpClient;
-
- public MailRuCloudService(HttpClient httpClient)
- {
- _httpClient = httpClient;
- }
-
- public async Task> GetObjects(string webLink)
- {
- var dto = await GetFromPublicApi(
- path: "/api/v4/public/list",
- query: new Dictionary
- {
- ["weblink"] = webLink,
- ["sort"] = "name",
- ["order"] = "asc",
- ["offset"] = "0",
- ["limit"] = "500",
- ["version"] = "4"
- }).ConfigureAwait(false);
-
- if (dto?.List is null || dto.List.Count == 0)
- return [];
-
- var result = new List(dto.List.Count);
- foreach (var entry in dto.List)
- result.Add(Map(entry));
-
- return result;
- }
-
- public async Task GetDownloadLink(IEnumerable webLinks, string outputFileName = "archive")
- {
- var result = await PostToPublicApi(
- path: "/api/v3/zip/weblink",
- query: new Dictionary(),
- new ZipWebLinkRequest
- {
- XEmail = "anonym",
- WeblinkList = webLinks.ToList(),
- Name = outputFileName
- }
- );
-
- return result?.Key;
- }
-
- private async Task GetFromPublicApi(string path, IReadOnlyDictionary query)
- {
- var uri = BuildUri(path, query);
-
- using var response = await _httpClient.GetAsync(uri).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
-
- var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
- return JsonSerializer.Deserialize(json, JsonOptions);
- }
-
- private async Task PostToPublicApi(string path, IReadOnlyDictionary query,
- object body)
- {
- var uri = BuildUri(path, query);
-
- var jsonBody = JsonSerializer.Serialize(body, JsonOptions);
- using var content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
-
- using var response = await _httpClient.PostAsync(uri, content).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
-
- var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
- return JsonSerializer.Deserialize(json, JsonOptions);
- }
-
- private static Uri BuildUri(string path, IReadOnlyDictionary query)
- {
- var builder = new UriBuilder(BaseUrl)
- {
- Port = -1,
- Path = path
- };
-
- var qs = HttpUtility.ParseQueryString(builder.Query);
- foreach (var (key, value) in query)
- qs[key] = value;
-
- builder.Query = qs.ToString();
- return builder.Uri;
- }
-
- private static CloudObject Map(CloudObjectBase entry)
- {
- return new CloudObject
- {
- Name = entry.Name,
- WebLink = entry.WebLink,
- Type = entry.Type == "folder" ? CloudObjectType.Folder : CloudObjectType.File
- };
- }
-}
\ No newline at end of file
diff --git a/MetaforceInstaller.Cloud/MetaforceInstaller.Cloud.csproj b/MetaforceInstaller.Cloud/MetaforceInstaller.Cloud.csproj
deleted file mode 100644
index 17b910f..0000000
--- a/MetaforceInstaller.Cloud/MetaforceInstaller.Cloud.csproj
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
- net9.0
- enable
- enable
-
-
-
diff --git a/MetaforceInstaller.Cloud/Models/CloudObject.cs b/MetaforceInstaller.Cloud/Models/CloudObject.cs
deleted file mode 100644
index 643a9d5..0000000
--- a/MetaforceInstaller.Cloud/Models/CloudObject.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace MetaforceInstaller.Cloud.Models;
-
-public class CloudObject
-{
- public string Name { get; set; }
- public string WebLink { get; set; }
- public CloudObjectType Type { get; set; }
-}
-
-public enum CloudObjectType
-{
- Folder,
- File
-}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Defaults.cs b/MetaforceInstaller.Core/Defaults.cs
new file mode 100644
index 0000000..ec22aad
--- /dev/null
+++ b/MetaforceInstaller.Core/Defaults.cs
@@ -0,0 +1,7 @@
+namespace MetaforceInstaller.Core;
+
+public static class Defaults
+{
+ public static readonly string StoragePath =
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) + Path.DirectorySeparatorChar + "MetaforceInstaller";
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Intefaces/IAdbService.cs b/MetaforceInstaller.Core/Intefaces/IAdbService.cs
new file mode 100644
index 0000000..444c237
--- /dev/null
+++ b/MetaforceInstaller.Core/Intefaces/IAdbService.cs
@@ -0,0 +1,18 @@
+using MetaforceInstaller.Core.Models;
+using MetaforceInstaller.Core.Models;
+
+namespace MetaforceInstaller.Core.Intefaces;
+
+public interface IAdbService
+{
+ event EventHandler? ProgressChanged;
+ event EventHandler? StatusChanged;
+
+ Task InstallApkAsync(string apkPath, IProgress? progress = null, CancellationToken cancellationToken = default);
+ Task CopyFileAsync(string localPath, string remotePath, IProgress? progress = null, CancellationToken cancellationToken = default);
+ DeviceInfo GetDeviceInfo();
+
+ // Синхронные версии для обратной совместимости
+ void InstallApk(string apkPath);
+ void CopyFile(string localPath, string remotePath);
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Intefaces/IStorageService.cs b/MetaforceInstaller.Core/Intefaces/IStorageService.cs
new file mode 100644
index 0000000..5967309
--- /dev/null
+++ b/MetaforceInstaller.Core/Intefaces/IStorageService.cs
@@ -0,0 +1,9 @@
+using MetaforceInstaller.Core.Models;
+
+namespace MetaforceInstaller.Core.Intefaces;
+
+public interface IStorageService
+{
+ AppData Load();
+ void Save(AppData data);
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/MetaforceInstaller.Core.csproj b/MetaforceInstaller.Core/MetaforceInstaller.Core.csproj
new file mode 100644
index 0000000..0b0eeda
--- /dev/null
+++ b/MetaforceInstaller.Core/MetaforceInstaller.Core.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+ PreserveNewest
+ AdbWinApi.dll
+
+
+ PreserveNewest
+ AdbWinUsbApi.dll
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MetaforceInstaller.Core/Models/ApkInfo.cs b/MetaforceInstaller.Core/Models/ApkInfo.cs
new file mode 100644
index 0000000..9f042bf
--- /dev/null
+++ b/MetaforceInstaller.Core/Models/ApkInfo.cs
@@ -0,0 +1,3 @@
+namespace MetaforceInstaller.Core.Models;
+
+public record ApkInfo(string PackageName, string VersionName, string VersionCode);
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Models/AppData.cs b/MetaforceInstaller.Core/Models/AppData.cs
new file mode 100644
index 0000000..5c37871
--- /dev/null
+++ b/MetaforceInstaller.Core/Models/AppData.cs
@@ -0,0 +1,6 @@
+namespace MetaforceInstaller.Core.Models;
+
+public class AppData
+{
+ public List Installations { get; set; } = new();
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Models/DeviceInfo.cs b/MetaforceInstaller.Core/Models/DeviceInfo.cs
new file mode 100644
index 0000000..802cd3c
--- /dev/null
+++ b/MetaforceInstaller.Core/Models/DeviceInfo.cs
@@ -0,0 +1,8 @@
+namespace MetaforceInstaller.Core.Models;
+
+public record DeviceInfo(
+ string SerialNumber,
+ string State,
+ string Model,
+ string Name
+);
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Models/InstallationData.cs b/MetaforceInstaller.Core/Models/InstallationData.cs
new file mode 100644
index 0000000..6f53baa
--- /dev/null
+++ b/MetaforceInstaller.Core/Models/InstallationData.cs
@@ -0,0 +1,9 @@
+namespace MetaforceInstaller.Core.Models;
+
+public class InstallationData
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public required string Title { get; set; }
+ public required InstallationParts Parts { get; set; }
+ public DateTime InstalledAt { get; set; } = DateTime.Now;
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Models/InstallationParts.cs b/MetaforceInstaller.Core/Models/InstallationParts.cs
new file mode 100644
index 0000000..62acb62
--- /dev/null
+++ b/MetaforceInstaller.Core/Models/InstallationParts.cs
@@ -0,0 +1,12 @@
+namespace MetaforceInstaller.Core.Models;
+
+public class InstallationParts
+{
+ public string? OculusClientPath { get; init; }
+ public string? PicoClientPath { get; init; }
+ public string? AndroidAdminPath { get; init; }
+ public string? AndroidContentPath { get; init; }
+ public string? WindowsContentPath { get; init; }
+ public string? WindowsAdminPath { get; init; }
+ public string? WindowsServerPath { get; init; }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Models/InstallationRequest.cs b/MetaforceInstaller.Core/Models/InstallationRequest.cs
new file mode 100644
index 0000000..c64b271
--- /dev/null
+++ b/MetaforceInstaller.Core/Models/InstallationRequest.cs
@@ -0,0 +1,8 @@
+namespace MetaforceInstaller.Core.Models;
+
+public class InstallationRequest
+{
+ public string ApkPath { get; set; }
+ public string ZipPath { get; set; }
+ public string OutputPath { get; set; }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Models/ProgressInfo.cs b/MetaforceInstaller.Core/Models/ProgressInfo.cs
new file mode 100644
index 0000000..cc09a74
--- /dev/null
+++ b/MetaforceInstaller.Core/Models/ProgressInfo.cs
@@ -0,0 +1,19 @@
+namespace MetaforceInstaller.Core.Models;
+
+public class ProgressInfo
+{
+ public int PercentageComplete { get; set; }
+ public long BytesTransferred { get; set; }
+ public long TotalBytes { get; set; }
+ public string? Message { get; set; }
+ public string? CurrentFile { get; set; }
+ public ProgressType Type { get; set; }
+}
+
+public enum ProgressType
+{
+ Installation,
+ FileCopy,
+ Extraction,
+ General
+}
diff --git a/MetaforceInstaller.Core/Services/AdbService.cs b/MetaforceInstaller.Core/Services/AdbService.cs
new file mode 100644
index 0000000..07ca0ed
--- /dev/null
+++ b/MetaforceInstaller.Core/Services/AdbService.cs
@@ -0,0 +1,263 @@
+using System.Reflection;
+using AdvancedSharpAdbClient;
+using AdvancedSharpAdbClient.DeviceCommands;
+using AdvancedSharpAdbClient.Models;
+using AdvancedSharpAdbClient.Receivers;
+using MetaforceInstaller.Core.Intefaces;
+using MetaforceInstaller.Core.Models;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace MetaforceInstaller.Core.Services;
+
+public class AdbService : IAdbService
+{
+ private readonly ILogger _logger;
+ private readonly AdbClient _adbClient;
+ private DeviceData _deviceData;
+ private DeviceMonitor? _deviceMonitor;
+
+ public event EventHandler? ProgressChanged;
+ public event EventHandler? StatusChanged;
+ public EventHandler? DeviceConnected;
+ public EventHandler? DeviceDisconnected;
+ public EventHandler? DeviceChanged;
+
+ public bool IsDeviceConnected => _deviceData != null && _deviceData.State == DeviceState.Online;
+
+ public AdbService(ILogger? logger = null)
+ {
+ _logger = logger ?? new NullLogger();
+ var adbPath = GetAdbPath();
+ var server = new AdbServer();
+ var serverStatus = server.StartServer(adbPath, restartServerIfNewer: false);
+ _adbClient = new AdbClient();
+ RefreshDeviceData();
+ StartDeviceMonitoring();
+ }
+
+ private void StartDeviceMonitoring()
+ {
+ try
+ {
+ _deviceMonitor = new DeviceMonitor(new AdbSocket(_adbClient.EndPoint));
+ _deviceMonitor.DeviceConnected += OnDeviceConnected;
+ _deviceMonitor.DeviceDisconnected += OnDeviceDisconnected;
+ _deviceMonitor.DeviceChanged += OnDeviceChanged;
+ _deviceMonitor.Start();
+
+ _logger.LogInformation("Device monitoring started");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError($"Failed to start device monitoring: {ex.Message}");
+ }
+ }
+
+ private void OnDeviceConnected(object? sender, DeviceDataEventArgs e)
+ {
+ _logger.LogInformation($"Device conn: {e.Device.Serial}");
+ RefreshDeviceData();
+ DeviceConnected?.Invoke(this, e);
+ }
+
+ private void OnDeviceDisconnected(object? sender, DeviceDataEventArgs e)
+ {
+ _logger.LogInformation($"Device disconnected: {e.Device.Serial}");
+ RefreshDeviceData();
+ DeviceDisconnected?.Invoke(this, e);
+ }
+
+ private void OnDeviceChanged(object? sender, DeviceDataEventArgs e)
+ {
+ _logger.LogInformation($"Device changed: {e.Device.Serial}");
+ RefreshDeviceData();
+ DeviceChanged?.Invoke(this, e);
+ }
+
+ public void RefreshDeviceData()
+ {
+ var devices = _adbClient.GetDevices();
+ _deviceData = devices.FirstOrDefault();
+ }
+
+ private void ExtractResource(string resourceName, string outputPath)
+ {
+ _logger.LogInformation($"Extracting resource: {resourceName} to {outputPath}");
+ using var stream = Assembly.GetAssembly(typeof(AdbService)).GetManifestResourceStream(resourceName);
+ using var fileStream = File.Create(outputPath);
+ stream.CopyTo(fileStream);
+ _logger.LogInformation($"Resource extracted: {resourceName} to {outputPath}");
+ }
+
+ private string GetAdbPath()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), "MetaforceInstaller", "adb");
+ Directory.CreateDirectory(tempDir);
+
+ var adbPath = Path.Combine(tempDir, "adb.exe");
+
+ if (File.Exists(adbPath)) return adbPath;
+ ExtractResource("MetaforceInstaller.Core.adb.adb.exe", adbPath);
+ ExtractResource("MetaforceInstaller.Core.adb.AdbWinApi.dll", Path.Combine(tempDir, "AdbWinApi.dll"));
+ ExtractResource("MetaforceInstaller.Core.adb.AdbWinUsbApi.dll", Path.Combine(tempDir, "AdbWinUsbApi.dll"));
+
+ return adbPath;
+ }
+
+ private void OnProgressChanged(ProgressInfo progressInfo)
+ {
+ ProgressChanged?.Invoke(this, progressInfo);
+ }
+
+ private void OnStatusChanged(string status)
+ {
+ StatusChanged?.Invoke(this, status);
+ }
+
+ public void InstallApk(string apkPath)
+ {
+ InstallApkAsync(apkPath).Wait();
+ }
+
+ public async Task InstallApkAsync(string apkPath, IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (!File.Exists(apkPath))
+ {
+ _logger.LogCritical("Error: Could not find APK file.");
+ return;
+ }
+
+ OnStatusChanged("Начинаем установку APK...");
+ _logger.LogInformation($"Installing APK: {apkPath}");
+
+ progress?.Report(new ProgressInfo
+ {
+ PercentageComplete = 0,
+ Message = "Подготовка к установке APK...",
+ Type = ProgressType.Installation,
+ CurrentFile = Path.GetFileName(apkPath)
+ });
+
+ var packageManager = new PackageManager(_adbClient, _deviceData);
+
+ await Task.Run(() =>
+ {
+ packageManager.InstallPackage(apkPath, installProgress =>
+ {
+ var progressInfo = new ProgressInfo
+ {
+ PercentageComplete = (int)installProgress.UploadProgress,
+ Message = $"Установка APK: {installProgress.UploadProgress:F1}%",
+ Type = ProgressType.Installation,
+ CurrentFile = Path.GetFileName(apkPath)
+ };
+
+ progress?.Report(progressInfo);
+ OnProgressChanged(progressInfo);
+ });
+ }, cancellationToken);
+
+ OnStatusChanged("APK успешно установлен!");
+ _logger.LogInformation("APK successfully installed!");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogCritical($"Error: {ex.Message}");
+ throw;
+ }
+ }
+
+ public void CopyFile(string localPath, string remotePath)
+ {
+ CopyFileAsync(localPath, remotePath).Wait();
+ }
+
+ public async Task CopyFileAsync(string localPath, string remotePath, IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (!File.Exists(localPath))
+ {
+ _logger.LogCritical($"Error: Could not find file: {localPath}");
+ return;
+ }
+
+ OnStatusChanged("Начинаем копирование файла...");
+ _logger.LogInformation($"Copying file: {localPath} to {remotePath}");
+
+ var fileInfo = new FileInfo(localPath);
+
+ progress?.Report(new ProgressInfo
+ {
+ PercentageComplete = 0,
+ Message = "Подготовка к копированию файла...",
+ Type = ProgressType.FileCopy,
+ CurrentFile = Path.GetFileName(localPath),
+ TotalBytes = fileInfo.Length
+ });
+
+ var remoteDir = Path.GetDirectoryName(remotePath)?.Replace('\\', '/');
+ if (!string.IsNullOrEmpty(remoteDir))
+ {
+ var reciever = new ConsoleOutputReceiver();
+ await Task.Run(
+ () => { _adbClient.ExecuteRemoteCommand($"mkdir -p \"{remoteDir}\"", _deviceData, reciever); },
+ cancellationToken);
+ }
+
+ _logger.LogInformation($"Ensured remote directory: {remoteDir}");
+
+ await Task.Run(() =>
+ {
+ using var fileStream = File.OpenRead(localPath);
+ var syncService = new SyncService(_adbClient, _deviceData);
+
+ syncService.Push(fileStream, remotePath, UnixFileStatus.DefaultFileMode, DateTime.Now,
+ copyProgress =>
+ {
+ var progressInfo = new ProgressInfo
+ {
+ PercentageComplete = (int)copyProgress.ProgressPercentage,
+ BytesTransferred = copyProgress.ReceivedBytesSize,
+ TotalBytes = copyProgress.TotalBytesToReceive,
+ Message = $"Копирование: {copyProgress.ProgressPercentage:F1}%",
+ Type = ProgressType.FileCopy,
+ CurrentFile = Path.GetFileName(localPath)
+ };
+
+ progress?.Report(progressInfo);
+ OnProgressChanged(progressInfo);
+ });
+ }, cancellationToken);
+
+ OnStatusChanged("Файл успешно скопирован!");
+ _logger.LogInformation("File successfully copied!");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogCritical($"Error: {ex.Message}");
+ throw;
+ }
+ }
+
+ public DeviceInfo GetDeviceInfo()
+ {
+ return new DeviceInfo(_deviceData.Serial, _deviceData.State.ToString(), _deviceData.Model, _deviceData.Name);
+ }
+
+ public void Dispose()
+ {
+ if (_deviceMonitor != null)
+ {
+ _deviceMonitor.DeviceConnected -= OnDeviceConnected;
+ _deviceMonitor.DeviceDisconnected -= OnDeviceDisconnected;
+ _deviceMonitor.DeviceChanged -= OnDeviceChanged;
+ _deviceMonitor.Dispose();
+ }
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Services/ApkScrapper.cs b/MetaforceInstaller.Core/Services/ApkScrapper.cs
new file mode 100644
index 0000000..c06c543
--- /dev/null
+++ b/MetaforceInstaller.Core/Services/ApkScrapper.cs
@@ -0,0 +1,19 @@
+using AlphaOmega.Debug;
+using MetaforceInstaller.Core.Models;
+
+namespace MetaforceInstaller.Core.Services;
+
+public static class ApkScrapper
+{
+ public static ApkInfo GetApkInfo(string apkPath)
+ {
+ using var apk = new ApkFile(apkPath);
+ if (apk is { IsValid: true, AndroidManifest: not null })
+ {
+ return new ApkInfo(apk.AndroidManifest.Package, apk.AndroidManifest.VersionName,
+ apk.AndroidManifest.VersionCode);
+ }
+
+ throw new Exception("Invalid APK file");
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Services/StorageService.cs b/MetaforceInstaller.Core/Services/StorageService.cs
new file mode 100644
index 0000000..4ab4ffa
--- /dev/null
+++ b/MetaforceInstaller.Core/Services/StorageService.cs
@@ -0,0 +1,35 @@
+using System.Text.Json;
+using MetaforceInstaller.Core.Intefaces;
+using MetaforceInstaller.Core.Models;
+
+namespace MetaforceInstaller.Core.Services;
+
+public class StorageService : IStorageService
+{
+ private readonly string _storagePath;
+
+ public StorageService()
+ {
+ var appDirectory = Defaults.StoragePath;
+ Directory.CreateDirectory(appDirectory);
+ _storagePath = Path.Combine(appDirectory, "installations.json");
+ }
+
+ public AppData Load()
+ {
+ if (!File.Exists(_storagePath))
+ return new AppData();
+
+ var json = File.ReadAllText(_storagePath);
+ return JsonSerializer.Deserialize(json) ?? new AppData();
+ }
+
+ public void Save(AppData data)
+ {
+ var json = JsonSerializer.Serialize(data, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ });
+ File.WriteAllText(_storagePath, json);
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/Services/ZipScrapper.cs b/MetaforceInstaller.Core/Services/ZipScrapper.cs
new file mode 100644
index 0000000..b5c4122
--- /dev/null
+++ b/MetaforceInstaller.Core/Services/ZipScrapper.cs
@@ -0,0 +1,160 @@
+using System.IO.Compression;
+using MetaforceInstaller.Core.Models;
+
+namespace MetaforceInstaller.Core.Services;
+
+public class ZipScrapper
+{
+ public static InstallationParts PeekFiles(ZipArchive archive)
+ {
+ return new InstallationParts
+ {
+ AndroidContentPath = FindAndroidContent(archive),
+ OculusClientPath = FindOculusClient(archive),
+ PicoClientPath = FindPicoClient(archive),
+ AndroidAdminPath = FindAndroidAdmin(archive),
+ WindowsAdminPath = FindPcAdmin(archive),
+ WindowsContentPath = FindWindowsContent(archive),
+ WindowsServerPath = FindServer(archive),
+ };
+ }
+
+ ///
+ /// Extracts ZIP archive to a unique folder based on installation GUID
+ ///
+ /// ZIP archive to extract
+ /// Base storage path
+ /// Unique GUID for this installation
+ /// Progress reporter
+ /// Full path to the extracted folder
+ public static string ExtractZip(
+ ZipArchive archive,
+ string baseOutputPath,
+ Guid installationGuid,
+ IProgress? progress = null)
+ {
+ // Create unique folder for this installation
+ var installationFolder = Path.Combine(baseOutputPath, installationGuid.ToString());
+ Directory.CreateDirectory(installationFolder);
+
+ var entries = archive.Entries.Where(e => !string.IsNullOrEmpty(e.Name)).ToList();
+ var totalEntries = entries.Count;
+ var processedEntries = 0;
+
+ foreach (var entry in entries)
+ {
+ var destinationPath = Path.Combine(installationFolder, entry.FullName);
+ var destinationDir = Path.GetDirectoryName(destinationPath);
+
+ if (!string.IsNullOrEmpty(destinationDir))
+ {
+ Directory.CreateDirectory(destinationDir);
+ }
+
+ entry.ExtractToFile(destinationPath, overwrite: true);
+
+ processedEntries++;
+ progress?.Report((double)processedEntries / totalEntries * 100);
+ }
+
+ return installationFolder;
+ }
+
+ ///
+ /// Updates InstallationParts paths to reflect the actual extracted location
+ ///
+ public static InstallationParts UpdatePathsAfterExtraction(
+ InstallationParts parts,
+ string extractedFolderPath)
+ {
+ return new InstallationParts
+ {
+ AndroidContentPath = UpdatePath(parts.AndroidContentPath, extractedFolderPath),
+ OculusClientPath = UpdatePath(parts.OculusClientPath, extractedFolderPath),
+ PicoClientPath = UpdatePath(parts.PicoClientPath, extractedFolderPath),
+ AndroidAdminPath = UpdatePath(parts.AndroidAdminPath, extractedFolderPath),
+ WindowsAdminPath = UpdatePath(parts.WindowsAdminPath, extractedFolderPath),
+ WindowsContentPath = UpdatePath(parts.WindowsContentPath, extractedFolderPath),
+ WindowsServerPath = UpdatePath(parts.WindowsServerPath, extractedFolderPath),
+ };
+ }
+
+ private static string? UpdatePath(string? relativePath, string basePath)
+ {
+ if (string.IsNullOrEmpty(relativePath))
+ return null;
+
+ return Path.Combine(basePath, relativePath);
+ }
+
+ private static string? FindPicoClient(ZipArchive archive)
+ {
+ return FindEntry(archive,
+ name: "MetaforcePico",
+ extension: ".apk");
+ }
+
+ private static string? FindOculusClient(ZipArchive archive)
+ {
+ return FindEntry(archive,
+ name: "MetaforceOculus",
+ extension: ".apk");
+ }
+
+ private static string? FindAndroidAdmin(ZipArchive archive)
+ {
+ return FindEntry(archive,
+ name: "MetaforceAdmin",
+ extension: ".apk");
+ }
+
+ private static string? FindAndroidContent(ZipArchive archive)
+ {
+ return FindEntry(archive,
+ name: "Content_Android",
+ extension: ".zip");
+ }
+
+ private static string? FindWindowsContent(ZipArchive archive)
+ {
+ return FindEntry(archive,
+ name: "Content_StandaloneWindows",
+ extension: ".zip");
+ }
+
+ private static string? FindPcAdmin(ZipArchive archive)
+ {
+ return FindExecutable(archive, "MetaforceAdminPC");
+ }
+
+ private static string? FindServer(ZipArchive archive)
+ {
+ return FindExecutable(archive, "MetaforceServer");
+ }
+
+ ///
+ /// Finds an entry in archive by name and extension
+ ///
+ private static string? FindEntry(ZipArchive archive, string name, string extension)
+ {
+ var entry = archive.Entries.FirstOrDefault(e =>
+ e.Name.Contains(name, StringComparison.OrdinalIgnoreCase) &&
+ e.Name.EndsWith(extension, StringComparison.OrdinalIgnoreCase));
+
+ return entry?.FullName;
+ }
+
+ ///
+ /// Finds an executable in archive, excluding crash handlers
+ ///
+ private static string? FindExecutable(ZipArchive archive, string containsName)
+ {
+ var entry = archive.Entries.FirstOrDefault(e =>
+ e.FullName.Contains(containsName, StringComparison.OrdinalIgnoreCase) &&
+ e.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) &&
+ !e.Name.Contains("UnityCrashHandler", StringComparison.OrdinalIgnoreCase) &&
+ !e.Name.Contains("crashpad_handler", StringComparison.OrdinalIgnoreCase));
+
+ return entry?.FullName;
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.Core/adb/AdbWinApi.dll b/MetaforceInstaller.Core/adb/AdbWinApi.dll
new file mode 100644
index 0000000..7abe26c
Binary files /dev/null and b/MetaforceInstaller.Core/adb/AdbWinApi.dll differ
diff --git a/MetaforceInstaller.Core/adb/AdbWinUsbApi.dll b/MetaforceInstaller.Core/adb/AdbWinUsbApi.dll
new file mode 100644
index 0000000..e7a6de1
Binary files /dev/null and b/MetaforceInstaller.Core/adb/AdbWinUsbApi.dll differ
diff --git a/MetaforceInstaller.Core/adb/adb.exe b/MetaforceInstaller.Core/adb/adb.exe
new file mode 100644
index 0000000..85bfeaa
Binary files /dev/null and b/MetaforceInstaller.Core/adb/adb.exe differ
diff --git a/MetaforceInstaller.UI/App.axaml b/MetaforceInstaller.UI/App.axaml
new file mode 100644
index 0000000..d947dd9
--- /dev/null
+++ b/MetaforceInstaller.UI/App.axaml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/App.axaml.cs b/MetaforceInstaller.UI/App.axaml.cs
new file mode 100644
index 0000000..38eec17
--- /dev/null
+++ b/MetaforceInstaller.UI/App.axaml.cs
@@ -0,0 +1,25 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using MetaforceInstaller.UI.Windows;
+
+namespace MetaforceInstaller.UI;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ Lang.Resources.Culture = System.Globalization.CultureInfo.CurrentCulture;
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/Images/logo_black.svg b/MetaforceInstaller.UI/Images/logo_black.svg
new file mode 100644
index 0000000..d41fcc9
--- /dev/null
+++ b/MetaforceInstaller.UI/Images/logo_black.svg
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/Images/logo_red.svg b/MetaforceInstaller.UI/Images/logo_red.svg
new file mode 100644
index 0000000..1b73fdc
--- /dev/null
+++ b/MetaforceInstaller.UI/Images/logo_red.svg
@@ -0,0 +1,31 @@
+
+
+
diff --git a/MetaforceInstaller.UI/Images/logo_white.svg b/MetaforceInstaller.UI/Images/logo_white.svg
new file mode 100644
index 0000000..12ba8d1
--- /dev/null
+++ b/MetaforceInstaller.UI/Images/logo_white.svg
@@ -0,0 +1,23 @@
+
+
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/Lang/Resources.Designer.cs b/MetaforceInstaller.UI/Lang/Resources.Designer.cs
new file mode 100644
index 0000000..a61e233
--- /dev/null
+++ b/MetaforceInstaller.UI/Lang/Resources.Designer.cs
@@ -0,0 +1,251 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace MetaforceInstaller.UI.Lang {
+ using System;
+
+
+ ///
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ ///
+ // This class was auto-generated by the StronglyTypedResourceBuilder
+ // class via a tool like ResGen or Visual Studio.
+ // To add or remove a member, edit your .ResX file then rerun ResGen
+ // with the /str option, or rebuild your VS project.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ public class Resources {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal Resources() {
+ }
+
+ ///
+ /// Returns the cached ResourceManager instance used by this class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ public static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MetaforceInstaller.UI.Lang.Resources", typeof(Resources).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ public static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Add new installation.
+ ///
+ public static string AddInstallation {
+ get {
+ return ResourceManager.GetString("AddInstallation", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Cancel.
+ ///
+ public static string CancelButton {
+ get {
+ return ResourceManager.GetString("CancelButton", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Choose .zip.
+ ///
+ public static string ChooseZip {
+ get {
+ return ResourceManager.GetString("ChooseZip", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Connected to.
+ ///
+ public static string ConnectedTo {
+ get {
+ return ResourceManager.GetString("ConnectedTo", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Delete.
+ ///
+ public static string Delete {
+ get {
+ return ResourceManager.GetString("Delete", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Install admin.
+ ///
+ public static string InstallAdminCheckbox {
+ get {
+ return ResourceManager.GetString("InstallAdminCheckbox", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Install Android admin.
+ ///
+ public static string InstallAndroidAdmin {
+ get {
+ return ResourceManager.GetString("InstallAndroidAdmin", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Install.
+ ///
+ public static string InstallButton {
+ get {
+ return ResourceManager.GetString("InstallButton", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Install server.
+ ///
+ public static string InstallServerCheckbox {
+ get {
+ return ResourceManager.GetString("InstallServerCheckbox", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Install VR client.
+ ///
+ public static string InstallVRClient {
+ get {
+ return ResourceManager.GetString("InstallVRClient", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Launch PC admin.
+ ///
+ public static string LaunchPCAdmin {
+ get {
+ return ResourceManager.GetString("LaunchPCAdmin", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Launch server.
+ ///
+ public static string LaunchServer {
+ get {
+ return ResourceManager.GetString("LaunchServer", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Name of new installation.
+ ///
+ public static string NameOfNewInstallation {
+ get {
+ return ResourceManager.GetString("NameOfNewInstallation", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Couldn't find Android content.
+ ///
+ public static string NoAndroidContentError {
+ get {
+ return ResourceManager.GetString("NoAndroidContentError", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Couldn't find directory with PC admin.
+ ///
+ public static string NoPCAdminError {
+ get {
+ return ResourceManager.GetString("NoPCAdminError", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Couldn't find directory with server.
+ ///
+ public static string NoServerError {
+ get {
+ return ResourceManager.GetString("NoServerError", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Not connected.
+ ///
+ public static string NotConnected {
+ get {
+ return ResourceManager.GetString("NotConnected", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Couldn't find any VR clients.
+ ///
+ public static string NoVRClientsError {
+ get {
+ return ResourceManager.GetString("NoVRClientsError", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Couldn't find Windows content.
+ ///
+ public static string NoWindowsContentError {
+ get {
+ return ResourceManager.GetString("NoWindowsContentError", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Save Android admin.
+ ///
+ public static string SaveAndroidAdminCheckbox {
+ get {
+ return ResourceManager.GetString("SaveAndroidAdminCheckbox", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Save VR client.
+ ///
+ public static string SaveVRClientCheckbox {
+ get {
+ return ResourceManager.GetString("SaveVRClientCheckbox", resourceCulture);
+ }
+ }
+ }
+}
diff --git a/MetaforceInstaller.UI/Lang/Resources.resx b/MetaforceInstaller.UI/Lang/Resources.resx
new file mode 100644
index 0000000..c641fa3
--- /dev/null
+++ b/MetaforceInstaller.UI/Lang/Resources.resx
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 1.3
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Launch server
+
+
+ Launch PC admin
+
+
+ Install VR client
+
+
+ Install Android admin
+
+
+ Delete
+
+
+ Add new installation
+
+
+ Not connected
+
+
+ Connected to
+
+
+ Name of new installation
+
+
+ Choose .zip
+
+
+ Install server
+
+
+ Install admin
+
+
+ Save Android admin
+
+
+ Save VR client
+
+
+ Install
+
+
+ Cancel
+
+
+ Couldn't find directory with server
+
+
+ Couldn't find directory with PC admin
+
+
+ Couldn't find Windows content
+
+
+ Couldn't find Android content
+
+
+ Couldn't find any VR clients
+
+
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/Lang/Resources.ru.resx b/MetaforceInstaller.UI/Lang/Resources.ru.resx
new file mode 100644
index 0000000..891e8d9
--- /dev/null
+++ b/MetaforceInstaller.UI/Lang/Resources.ru.resx
@@ -0,0 +1,77 @@
+
+
+ text/microsoft-resx
+
+
+ 1.3
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Запустить сервер
+
+
+ Запустить админку
+
+
+ Удалить
+
+
+ Установить андроид-админку
+
+
+ Установить на шлем
+
+
+ Добавить версию
+
+
+ Подключено:
+
+
+ Не подключено
+
+
+ Выбрать .zip
+
+
+ Отмена
+
+
+ Установить админку
+
+
+ Установить
+
+
+ Установить сервер
+
+
+ Название версии
+
+
+ Сохранить андроид-админку
+
+
+ Сохранить VR-клиенты
+
+
+ Не удалось обнаружить Андроид контент
+
+
+ Не удалось обнаружить ПК-админку
+
+
+ Не удалось обнаружить сервер
+
+
+ Не удалось обнаружить VR-клиенты
+
+
+ Не удалось обнаружить Windows контент
+
+
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/MetaforceInstaller.UI.csproj b/MetaforceInstaller.UI/MetaforceInstaller.UI.csproj
new file mode 100644
index 0000000..3a83cb5
--- /dev/null
+++ b/MetaforceInstaller.UI/MetaforceInstaller.UI.csproj
@@ -0,0 +1,52 @@
+
+
+ WinExe
+ net8.0
+ enable
+ true
+ true
+ true
+ app.manifest
+ true
+ 2.0.0-b1
+
+
+
+
+
+
+
+
+
+ None
+ All
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PublicResXFileCodeGenerator
+ Resources.Designer.cs
+
+
+
+
+
+ True
+ True
+ Resources.resx
+
+
+
+
diff --git a/MetaforceInstaller.UI/Program.cs b/MetaforceInstaller.UI/Program.cs
new file mode 100644
index 0000000..c77ddc1
--- /dev/null
+++ b/MetaforceInstaller.UI/Program.cs
@@ -0,0 +1,21 @@
+using Avalonia;
+using System;
+
+namespace MetaforceInstaller.UI;
+
+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()
+ .WithInterFont()
+ .LogToTrace();
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/ViewModels/MainWindowViewModel.cs b/MetaforceInstaller.UI/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..22c0005
--- /dev/null
+++ b/MetaforceInstaller.UI/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using Avalonia.Media;
+using MetaforceInstaller.Core.Models;
+
+namespace MetaforceInstaller.UI.ViewModels;
+
+public class MainWindowViewModel : INotifyPropertyChanged
+{
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public ObservableCollection Installations { get; set; } = new();
+
+ private bool _isDeviceConnected;
+
+ public bool IsDeviceConnected
+ {
+ get => _isDeviceConnected;
+ set
+ {
+ if (_isDeviceConnected != value)
+ {
+ _isDeviceConnected = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(StatusColor));
+ OnPropertyChanged(nameof(StatusText));
+ }
+ }
+ }
+
+ private string _deviceSerial = string.Empty;
+
+ public string DeviceSerial
+ {
+ get => _deviceSerial;
+ set
+ {
+ if (_deviceSerial != value)
+ {
+ _deviceSerial = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(StatusText));
+ }
+ }
+ }
+
+ public IBrush StatusColor => IsDeviceConnected ? Brushes.Green : Brushes.Red;
+ public string StatusText => IsDeviceConnected ? $"{Lang.Resources.ConnectedTo} {_deviceSerial}" : Lang.Resources.NotConnected;
+
+ public void LoadInstallations(IEnumerable data)
+ {
+ Installations.Clear();
+ foreach (var installation in data)
+ {
+ Installations.Add(installation);
+ }
+ }
+
+ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/Windows/MainWindow.axaml b/MetaforceInstaller.UI/Windows/MainWindow.axaml
new file mode 100644
index 0000000..e7d4655
--- /dev/null
+++ b/MetaforceInstaller.UI/Windows/MainWindow.axaml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/Windows/MainWindow.axaml.cs b/MetaforceInstaller.UI/Windows/MainWindow.axaml.cs
new file mode 100644
index 0000000..8ab161b
--- /dev/null
+++ b/MetaforceInstaller.UI/Windows/MainWindow.axaml.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Diagnostics;
+using System.Reflection;
+using AdvancedSharpAdbClient.Models;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using MetaforceInstaller.Core.Intefaces;
+using MetaforceInstaller.Core.Models;
+using MetaforceInstaller.Core.Services;
+using MetaforceInstaller.UI.ViewModels;
+
+namespace MetaforceInstaller.UI.Windows;
+
+public partial class MainWindow : Window
+{
+ private MainWindowViewModel _viewModel;
+ private IStorageService _storageService;
+ private AdbService _adbService;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+
+ _viewModel = new MainWindowViewModel();
+ _storageService = new StorageService();
+ _adbService = new AdbService();
+
+ DataContext = _viewModel;
+
+ VersionLabel.Content = Assembly.GetExecutingAssembly()
+ .GetCustomAttribute()?.Version;
+
+ NewInstallationButton.Click += OnNewInstalltionClick;
+
+ _adbService.DeviceConnected += OnAdbDeviceConnected;
+ _adbService.DeviceDisconnected += OnAdbDeviceDisconnected;
+ _adbService.DeviceChanged += OnAdbDeviceChanged;
+
+ LoadInstallations();
+ UpdateDeviceStatus();
+ }
+
+ private void UpdateDeviceStatus()
+ {
+ var isConnected = _adbService.IsDeviceConnected;
+ _viewModel.IsDeviceConnected = isConnected;
+
+ if (isConnected)
+ {
+ try
+ {
+ var deviceInfo = _adbService.GetDeviceInfo();
+ _viewModel.DeviceSerial = deviceInfo.SerialNumber;
+ }
+ catch
+ {
+ _viewModel.DeviceSerial = "Unknown";
+ }
+ }
+ else
+ {
+ _viewModel.DeviceSerial = string.Empty;
+ }
+ }
+
+ private void OnAdbDeviceConnected(object? sender, DeviceDataEventArgs e)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ UpdateDeviceStatus();
+ });
+ }
+
+ private void OnAdbDeviceDisconnected(object? sender, DeviceDataEventArgs e)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ UpdateDeviceStatus();
+ });
+ }
+
+ private void OnAdbDeviceChanged(object? sender, DeviceDataEventArgs e)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ UpdateDeviceStatus();
+ });
+ }
+
+ private void LoadInstallations()
+ {
+ var appData = _storageService.Load();
+ _viewModel.LoadInstallations(appData.Installations);
+ }
+
+ public async void OnNewInstalltionClick(object? sender, RoutedEventArgs e)
+ {
+ var newInstallationDialog = new NewInstallationDialog(_storageService);
+ await newInstallationDialog.ShowDialog(this);
+ LoadInstallations();
+ }
+
+ public async void OnDeleteInstallationClick(object? sender, RoutedEventArgs e)
+ {
+ if (sender is Button button && button.DataContext is InstallationData installationData)
+ {
+ var name = installationData.Title;
+ Console.WriteLine($"Delete {name}");
+ }
+ }
+
+ public async void OnLaunchServerClick(object? sender, RoutedEventArgs e)
+ {
+ if (sender is Button button && button.DataContext is InstallationData installationData)
+ {
+ var exePath = installationData.Parts.WindowsServerPath;
+ var processInfo = new ProcessStartInfo
+ {
+ FileName = exePath,
+ UseShellExecute = false,
+ };
+ Process.Start(processInfo);
+ }
+ }
+
+ public async void OnLaunchAdminClick(object? sender, RoutedEventArgs e)
+ {
+ if (sender is Button button && button.DataContext is InstallationData installationData)
+ {
+ var exePath = installationData.Parts.WindowsAdminPath;
+ var processInfo = new ProcessStartInfo
+ {
+ FileName = exePath,
+ UseShellExecute = false,
+ };
+ Process.Start(processInfo);
+ }
+ }
+
+ protected override void OnClosed(EventArgs e)
+ {
+ _adbService.DeviceConnected -= OnAdbDeviceConnected;
+ _adbService.DeviceDisconnected -= OnAdbDeviceDisconnected;
+ _adbService.DeviceChanged -= OnAdbDeviceChanged;
+ _adbService.Dispose();
+
+ base.OnClosed(e);
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/Windows/NewInstallationDialog.axaml b/MetaforceInstaller.UI/Windows/NewInstallationDialog.axaml
new file mode 100644
index 0000000..d9a410a
--- /dev/null
+++ b/MetaforceInstaller.UI/Windows/NewInstallationDialog.axaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/Windows/NewInstallationDialog.axaml.cs b/MetaforceInstaller.UI/Windows/NewInstallationDialog.axaml.cs
new file mode 100644
index 0000000..da8b7a1
--- /dev/null
+++ b/MetaforceInstaller.UI/Windows/NewInstallationDialog.axaml.cs
@@ -0,0 +1,164 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using MetaforceInstaller.Core;
+using MetaforceInstaller.Core.Intefaces;
+using MetaforceInstaller.Core.Models;
+using MetaforceInstaller.Core.Services;
+
+namespace MetaforceInstaller.UI.Windows;
+
+public partial class NewInstallationDialog : Window
+{
+ private string? _zipPath;
+ private InstallationParts? _installationParts;
+ private readonly IStorageService _storageService;
+
+ public NewInstallationDialog(IStorageService storageService)
+ {
+ InitializeComponent();
+
+ _storageService = storageService;
+
+ RefreshCheckboxes();
+ CancelButton.Click += OnCancelClick;
+ ChooseZip.Click += OnChooseZipClick;
+ InstallButton.IsEnabled = false;
+ InstallButton.Click += OnInstallClick;
+ }
+
+ private void RefreshCheckboxes()
+ {
+ var serverCheckbox = ServerCheckBox;
+ var pcAdminCheckbox = PcAdminCheckBox;
+ var androidAdminCheckbox = AndroidAdminCheckbox;
+ var vrClientCheckbox = VrClientCheckbox;
+ serverCheckbox.Content = Lang.Resources.InstallServerCheckbox;
+ serverCheckbox.IsEnabled = true;
+ pcAdminCheckbox.Content = Lang.Resources.InstallAdminCheckbox;
+ pcAdminCheckbox.IsEnabled = true;
+ androidAdminCheckbox.Content = Lang.Resources.SaveAndroidAdminCheckbox;
+ androidAdminCheckbox.IsEnabled = true;
+ vrClientCheckbox.Content = Lang.Resources.SaveVRClientCheckbox;
+ vrClientCheckbox.IsEnabled = true;
+ }
+
+ private async void OnChooseZipClick(object? sender, RoutedEventArgs e)
+ {
+ var topLevel = GetTopLevel(this);
+ var files = await topLevel!.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Выберите архив с контентом",
+ AllowMultiple = false,
+ FileTypeFilter =
+ [
+ new FilePickerFileType("ZIP Files")
+ {
+ Patterns = ["*.zip"]
+ }
+ ]
+ });
+
+ if (files.Count >= 1)
+ {
+ _zipPath = files[0].Path.LocalPath;
+ using var archive = ZipFile.OpenRead(_zipPath);
+ _installationParts = ZipScrapper.PeekFiles(archive);
+ UpdateCheckboxes();
+ archive.Dispose();
+ }
+ }
+
+ private async void OnInstallClick(object? sender, RoutedEventArgs e)
+ {
+ using var archive = ZipFile.OpenRead(_zipPath);
+ var title = TitleTextBox.Text ?? Path.GetFileNameWithoutExtension(_zipPath);
+ var installationGuid = Guid.NewGuid();
+
+ var progress = new Progress(value => { ProgressBar.Value = value; });
+
+ string extractedPath = null;
+ await Task.Run(() =>
+ {
+ extractedPath = ZipScrapper.ExtractZip(
+ archive,
+ Defaults.StoragePath,
+ installationGuid,
+ progress);
+ });
+
+ InstallButton.IsEnabled = false;
+
+ var appData = _storageService.Load();
+
+ var updatedParts = ZipScrapper.UpdatePathsAfterExtraction(_installationParts, extractedPath);
+
+ var installationData = new InstallationData
+ {
+ Id = installationGuid,
+ Title = title,
+ Parts = updatedParts
+ };
+
+ appData.Installations.Add(installationData);
+ _storageService.Save(appData);
+ Close();
+ }
+
+ private void UpdateCheckboxes()
+ {
+ RefreshCheckboxes();
+ var serverCheckbox = ServerCheckBox;
+ var pcAdminCheckbox = PcAdminCheckBox;
+ var androidAdminCheckbox = AndroidAdminCheckbox;
+ var vrClientCheckbox = VrClientCheckbox;
+
+
+ if (string.IsNullOrEmpty(_installationParts.WindowsServerPath))
+ {
+ serverCheckbox.IsEnabled = false;
+ serverCheckbox.Content += $"\n{Lang.Resources.NoServerError}";
+ }
+
+ if (string.IsNullOrEmpty(_installationParts.WindowsAdminPath))
+ {
+ pcAdminCheckbox.IsEnabled = false;
+ pcAdminCheckbox.Content += $"\n{Lang.Resources.NoPCAdminError}";
+ }
+
+ if (string.IsNullOrEmpty(_installationParts.WindowsContentPath))
+ {
+ pcAdminCheckbox.IsEnabled = false;
+ pcAdminCheckbox.Content += $"\n{Lang.Resources.NoWindowsContentError}";
+ }
+
+ if (string.IsNullOrEmpty(_installationParts.AndroidContentPath))
+ {
+ vrClientCheckbox.IsEnabled = false;
+ vrClientCheckbox.Content += $"\n{Lang.Resources.NoAndroidContentError}";
+ androidAdminCheckbox.IsEnabled = false;
+ androidAdminCheckbox.Content += $"\n{Lang.Resources.NoAndroidContentError}";
+ }
+
+ if (string.IsNullOrEmpty(_installationParts.PicoClientPath) &&
+ string.IsNullOrEmpty(_installationParts.OculusClientPath))
+ {
+ vrClientCheckbox.IsEnabled = false;
+ vrClientCheckbox.Content += $"\n{Lang.Resources.NoVRClientsError}";
+ }
+
+ InstallButton.IsEnabled = new List
+ { serverCheckbox, pcAdminCheckbox, androidAdminCheckbox, vrClientCheckbox }.Any(x => x?.IsEnabled == true);
+ }
+
+ private void OnCancelClick(object? sender, RoutedEventArgs e)
+ {
+ Close();
+ }
+}
\ No newline at end of file
diff --git a/MetaforceInstaller.UI/app.manifest b/MetaforceInstaller.UI/app.manifest
new file mode 100644
index 0000000..0543ed4
--- /dev/null
+++ b/MetaforceInstaller.UI/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+