diff --git a/Daybreak.Core/Configuration/ProjectConfiguration.cs b/Daybreak.Core/Configuration/ProjectConfiguration.cs index fc867852..33f76166 100644 --- a/Daybreak.Core/Configuration/ProjectConfiguration.cs +++ b/Daybreak.Core/Configuration/ProjectConfiguration.cs @@ -318,6 +318,7 @@ public class ProjectConfiguration : PluginConfigurationBase startupActionProducer.RegisterAction(); startupActionProducer.RegisterAction(); startupActionProducer.RegisterAction(); + startupActionProducer.RegisterAction(); } public override void RegisterPostUpdateActions( diff --git a/Daybreak.Core/Daybreak.Core.csproj b/Daybreak.Core/Daybreak.Core.csproj index 4498b258..80865133 100644 --- a/Daybreak.Core/Daybreak.Core.csproj +++ b/Daybreak.Core/Daybreak.Core.csproj @@ -54,6 +54,7 @@ + diff --git a/Daybreak.Core/Services/DirectSong/DirectSongService.cs b/Daybreak.Core/Services/DirectSong/DirectSongService.cs index 785cde94..62a048fd 100644 --- a/Daybreak.Core/Services/DirectSong/DirectSongService.cs +++ b/Daybreak.Core/Services/DirectSong/DirectSongService.cs @@ -1,14 +1,14 @@ -using Daybreak.Configuration.Options; +using CG.Web.MegaApiClient; +using Daybreak.Configuration.Options; using Daybreak.Shared.Models.Async; using Daybreak.Shared.Models.Mods; using Daybreak.Shared.Services.DirectSong; using Daybreak.Shared.Services.Downloads; +using Daybreak.Shared.Services.Github; using Daybreak.Shared.Services.Notifications; using Daybreak.Shared.Services.Options; -using Daybreak.Shared.Services.Privilege; using Daybreak.Shared.Services.SevenZip; using Daybreak.Shared.Utils; -using Daybreak.Views; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Core.Extensions; @@ -20,35 +20,46 @@ namespace Daybreak.Services.DirectSong; internal sealed class DirectSongService( IOptionsProvider optionsProvider, INotificationService notificationService, - IPrivilegeManager privilegeManager, ISevenZipExtractor sevenZipExtractor, IDownloadService downloadService, IDirectSongRegistrar directSongRegistrar, + IGithubClient githubClient, IOptionsMonitor options, ILogger logger) : IDirectSongService { - private const string DownloadUrl = "https://guildwarslegacy.com/DirectSong.7z"; + // Mega URL for DirectSong Revival Pack (more complete than the legacy pack) + private const string MegaDownloadUrl = "https://mega.nz/file/P2pWGK7C#FLZZOOWE1c5gYSgCqD4MC464m6ZvK1oGTlS08hLpnKw"; private const string RegistryEditorName = "RegisterDirectSongDirectory.exe"; private const string InstallationDirectorySubPath = "DirectSong"; private const string DestinationZipFile = "DirectSong.7z"; - private const string WMVCOREDll = "WMVCORE.DLL"; - private const string DsGuildwarsDll = "ds_GuildWars.dll"; + + // DirectSong Remix from ChthonVII - enhanced GuildWars.ds playlist + private const string RemixRepoOwner = "ChthonVII"; + private const string RemixRepoName = "gwdirectsongremix"; + private const string RemixBranch = "main"; + private const string RemixFilePath = "GuildWars.ds"; + private const string RemixVersionFile = "GuildWars.ds.version"; + private const string RemixAudioFolder = "GWDirectSongRemix"; + private const string DirectSongSubdir = "DirectSong"; private static readonly ProgressUpdate ProgressStarting = new(0, "Starting DirectSong installation"); - private static readonly ProgressUpdate ProgressInitializingDownload = new(0, "Initializing DirectSong download"); + private static readonly ProgressUpdate ProgressPlatformFiles = new(0.78, "Setting up platform-specific files"); private static readonly ProgressUpdate ProgressFailed = new(1, "DirectSong installation failed"); private static readonly ProgressUpdate ProgressCompleted = new(1, "DirectSong installation completed"); - private static readonly ProgressUpdate ProgressRegistry = new(1, "Setting up DirectSong registry entries"); - private static ProgressUpdate ProgressUnpacking(double progress) => new(progress, "Unpacking DirectSong files"); + private static readonly ProgressUpdate ProgressRegistry = new(0.85, "Setting up DirectSong registry entries"); + private static readonly ProgressUpdate ProgressDllOverrides = new(0.90, "Setting up DLL overrides"); + private static readonly ProgressUpdate ProgressRemix = new(0.95, "Downloading DirectSong Remix playlist"); + private static ProgressUpdate ProgressDownloading(double progress) => new(Math.Clamp(progress * 0.50, 0, 0.50), $"Downloading DirectSong Revival Pack: {progress:P0}"); + private static ProgressUpdate ProgressUnpacking(double progress) => new(Math.Clamp((progress * 0.25) + 0.50, 0.50, 0.75), "Unpacking DirectSong files"); private static readonly string InstallationDirectory = PathUtils.GetAbsolutePathFromRoot(InstallationDirectorySubPath); private readonly IOptionsProvider optionsProvider = optionsProvider.ThrowIfNull(); private readonly INotificationService notificationService = notificationService.ThrowIfNull(); - private readonly IPrivilegeManager privilegeManager = privilegeManager.ThrowIfNull(); private readonly ISevenZipExtractor sevenZipExtractor = sevenZipExtractor.ThrowIfNull(); private readonly IDownloadService downloadService = downloadService.ThrowIfNull(); private readonly IDirectSongRegistrar directSongRegistrar = directSongRegistrar.ThrowIfNull(); + private readonly IGithubClient githubClient = githubClient.ThrowIfNull(); private readonly IOptionsMonitor options = options.ThrowIfNull(); private readonly ILogger logger = logger.ThrowIfNull(); @@ -70,8 +81,7 @@ internal sealed class DirectSongService( } public bool IsInstalled => Directory.Exists(InstallationDirectory) && - File.Exists(Path.Combine(Path.GetFullPath(InstallationDirectory), WMVCOREDll)) && - File.Exists(Path.Combine(Path.GetFullPath(InstallationDirectory), DsGuildwarsDll)); + this.directSongRegistrar.ArePlatformFilesInstalled(InstallationDirectory); public IProgressAsyncOperation PerformUninstallation(CancellationToken cancellationToken) { @@ -111,11 +121,19 @@ internal sealed class DirectSongService( public IEnumerable GetCustomArguments() => []; - public Task IsUpdateAvailable(CancellationToken cancellationToken) => Task.FromResult(false); - - public Task PerformUpdate(CancellationToken cancellationToken) + public async Task IsUpdateAvailable(CancellationToken cancellationToken) { - throw new NotImplementedException("DirectSong mod does not support manual updates"); + return await this.IsRemixUpdateAvailable(cancellationToken); + } + + public async Task PerformUpdate(CancellationToken cancellationToken) + { + if (!this.IsInstalled) + { + return false; + } + + return await this.UpdateRemixPlaylist(cancellationToken); } public Task ShouldRunAgain(GuildWarsRunningContext guildWarsRunningContext, CancellationToken cancellationToken) => Task.FromResult(false); @@ -135,17 +153,8 @@ internal sealed class DirectSongService( var gwPath = Path.GetFullPath(guildWarsStartingContext.ApplicationLauncherContext.ExecutablePath); var gwDirectory = Path.GetDirectoryName(gwPath)!; - var wmCorePath = Path.Combine(gwDirectory, WMVCOREDll); - var dsGuildWarsDll = Path.Combine(gwDirectory, DsGuildwarsDll); - if (!File.Exists(wmCorePath)) - { - File.Copy(Path.Combine(Path.GetFullPath(InstallationDirectory), WMVCOREDll), wmCorePath); - } - if (!File.Exists(dsGuildWarsDll)) - { - File.Copy(Path.Combine(Path.GetFullPath(InstallationDirectory), DsGuildwarsDll), dsGuildWarsDll); - } + this.directSongRegistrar.CopyFilesToGuildWars(InstallationDirectory, gwDirectory); this.notificationService.NotifyInformation( title: "DirectSong started", @@ -157,18 +166,8 @@ internal sealed class DirectSongService( { var gwPath = Path.GetFullPath(guildWarsStartingDisabledContext.ApplicationLauncherContext.ExecutablePath); var gwDirectory = Path.GetDirectoryName(gwPath)!; - var wmCorePath = Path.Combine(gwDirectory, WMVCOREDll); - var dsGuildWarsDll = Path.Combine(gwDirectory, DsGuildwarsDll); - if (File.Exists(wmCorePath)) - { - File.Delete(wmCorePath); - } - - if (File.Exists(dsGuildWarsDll)) - { - File.Delete(dsGuildWarsDll); - } + this.directSongRegistrar.RemoveFilesFromGuildWars(gwDirectory); return Task.CompletedTask; } @@ -187,10 +186,16 @@ internal sealed class DirectSongService( return true; } - if (!this.privilegeManager.AdminPrivileges) + // Check for gstreamer availability (no-op on Windows) + if (!this.directSongRegistrar.IsGStreamerAvailable()) { - await this.privilegeManager.RequestAdminPrivileges("DirectSong installation requires Administrator privileges in order to set up the registry entries", cancelViewParams: default, cancellationToken); - return false; + var instructions = this.directSongRegistrar.GetGStreamerInstallInstructions(); + this.notificationService.NotifyError( + title: "GStreamer libav plugin missing", + description: instructions, + expirationTime: DateTime.UtcNow + TimeSpan.FromMinutes(5)); + scopedLogger.LogWarning("GStreamer libav plugin not found. DirectSong WMA playback may not work."); + // Continue with installation - the user can install gstreamer later } progress.Report(ProgressStarting); @@ -198,11 +203,34 @@ internal sealed class DirectSongService( if (!File.Exists(destinationPath)) { Directory.CreateDirectory(Path.GetFullPath(InstallationDirectory)); - scopedLogger.LogDebug($"Downloading {DestinationZipFile}"); - progress.Report(ProgressInitializingDownload); - if (!await this.downloadService.DownloadFile(DownloadUrl, destinationPath, progress, cancellationToken)) + scopedLogger.LogDebug($"Downloading DirectSong Revival Pack from Mega"); + progress.Report(ProgressDownloading(0)); + + try { - scopedLogger.LogError("Download failed. Check logs"); + var megaClient = new MegaApiClient(); + await megaClient.LoginAnonymousAsync(); + + var fileUri = new Uri(MegaDownloadUrl); + var node = await megaClient.GetNodeFromLinkAsync(fileUri); + + scopedLogger.LogDebug($"Downloading {node.Name} ({node.Size} bytes)"); + + using var downloadStream = await megaClient.DownloadAsync(fileUri, new Progress(p => + { + // MegaApiClient reports progress as 0-100, normalize to 0-1 + var normalizedProgress = p / 100.0; + progress.Report(ProgressDownloading(normalizedProgress)); + }), cancellationToken); + + using var fileStream = File.Create(destinationPath); + await downloadStream.CopyToAsync(fileStream, cancellationToken); + + await megaClient.LogoutAsync(); + } + catch (Exception ex) + { + scopedLogger.LogError(ex, "Failed to download from Mega"); progress.Report(ProgressFailed); return false; } @@ -222,7 +250,17 @@ internal sealed class DirectSongService( return false; } - scopedLogger.LogDebug("Extracted files. Setting up registry entries"); + // Set up platform-specific files (downloads extra DLLs on Linux, no-op on Windows) + scopedLogger.LogDebug("Setting up platform-specific files"); + progress.Report(ProgressPlatformFiles); + if (!await this.directSongRegistrar.SetupPlatformFiles(InstallationDirectory, this.downloadService, progress, cancellationToken)) + { + scopedLogger.LogError("Failed to set up platform-specific files"); + progress.Report(ProgressFailed); + return false; + } + + scopedLogger.LogDebug("Setting up registry entries"); progress.Report(ProgressRegistry); if (!await this.directSongRegistrar.RegisterDirectSongDirectory(InstallationDirectory, RegistryEditorName, cancellationToken)) { @@ -231,9 +269,212 @@ internal sealed class DirectSongService( return false; } + // Set up DLL overrides (wmvcore, wmasf to native,builtin) - no-op on Windows + scopedLogger.LogDebug("Setting up DLL overrides"); + progress.Report(ProgressDllOverrides); + if (!await this.directSongRegistrar.SetupDllOverrides(cancellationToken)) + { + scopedLogger.LogError("Failed to set up DLL overrides"); + progress.Report(ProgressFailed); + return false; + } + + // Download DirectSong Remix GuildWars.ds from ChthonVII's repo + scopedLogger.LogDebug("Downloading DirectSong Remix playlist"); + progress.Report(ProgressRemix); + var directSongDir = Path.Combine(Path.GetFullPath(InstallationDirectory), DirectSongSubdir); + var guildWarsDsPath = Path.Combine(directSongDir, RemixFilePath); + var versionFilePath = Path.Combine(directSongDir, RemixVersionFile); + + if (await this.githubClient.DownloadRawFile(RemixRepoOwner, RemixRepoName, RemixBranch, RemixFilePath, guildWarsDsPath, cancellationToken)) + { + // Save the commit SHA for version tracking + var commitSha = await this.githubClient.GetLatestCommitSha(RemixRepoOwner, RemixRepoName, RemixBranch, cancellationToken); + if (!string.IsNullOrEmpty(commitSha)) + { + await File.WriteAllTextAsync(versionFilePath, commitSha, cancellationToken); + scopedLogger.LogDebug("Downloaded DirectSong Remix (commit {CommitSha})", commitSha); + } + else + { + scopedLogger.LogDebug("Downloaded DirectSong Remix (commit SHA unavailable)"); + } + } + else + { + scopedLogger.LogWarning("Failed to download DirectSong Remix, using default GuildWars.ds from Revival Pack"); + // Not a fatal error - the original GuildWars.ds from the Revival Pack will still work + } + + // Sync audio files from the GWDirectSongRemix folder + await this.SyncRemixAudioFiles(cancellationToken); + scopedLogger.LogDebug($"Deleting archive {destinationPath}"); File.Delete(destinationPath); progress.Report(ProgressCompleted); return true; } + + /// + /// Gets the currently installed DirectSong Remix commit SHA from the version file. + /// + private string? GetInstalledRemixCommitSha() + { + var directSongDir = Path.Combine(Path.GetFullPath(InstallationDirectory), DirectSongSubdir); + var versionFilePath = Path.Combine(directSongDir, RemixVersionFile); + + if (!File.Exists(versionFilePath)) + { + return null; + } + + try + { + // Version file contains just the commit SHA + return File.ReadAllText(versionFilePath).Trim(); + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Failed to read remix version file"); + return null; + } + } + + /// + /// Checks if a newer version of the DirectSong Remix playlist is available. + /// + private async Task IsRemixUpdateAvailable(CancellationToken cancellationToken) + { + if (!this.IsInstalled) + { + return false; + } + + var installedSha = this.GetInstalledRemixCommitSha(); + if (string.IsNullOrEmpty(installedSha)) + { + // No version file means we should try to download/update + return true; + } + + var latestSha = await this.githubClient.GetLatestCommitSha(RemixRepoOwner, RemixRepoName, RemixBranch, cancellationToken); + if (string.IsNullOrEmpty(latestSha)) + { + // Can't determine latest version, assume no update + return false; + } + + // Compare SHAs (case-insensitive since git SHAs are hex) + var updateAvailable = !installedSha.Equals(latestSha, StringComparison.OrdinalIgnoreCase); + if (updateAvailable) + { + this.logger.LogDebug("DirectSong Remix update available. Installed: {Installed}, Latest: {Latest}", installedSha, latestSha); + } + + return updateAvailable; + } + + /// + /// Updates the DirectSong Remix playlist to the latest version. + /// + private async Task UpdateRemixPlaylist(CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + var directSongDir = Path.Combine(Path.GetFullPath(InstallationDirectory), DirectSongSubdir); + var guildWarsDsPath = Path.Combine(directSongDir, RemixFilePath); + var versionFilePath = Path.Combine(directSongDir, RemixVersionFile); + + scopedLogger.LogDebug("Updating DirectSong Remix playlist"); + + if (!await this.githubClient.DownloadRawFile(RemixRepoOwner, RemixRepoName, RemixBranch, RemixFilePath, guildWarsDsPath, cancellationToken)) + { + scopedLogger.LogError("Failed to download DirectSong Remix update"); + return false; + } + + // Sync audio files from the GWDirectSongRemix folder + await this.SyncRemixAudioFiles(cancellationToken); + + // Save the new commit SHA + var commitSha = await this.githubClient.GetLatestCommitSha(RemixRepoOwner, RemixRepoName, RemixBranch, cancellationToken); + if (!string.IsNullOrEmpty(commitSha)) + { + await File.WriteAllTextAsync(versionFilePath, commitSha, cancellationToken); + scopedLogger.LogDebug("Updated DirectSong Remix to commit {CommitSha}", commitSha); + this.notificationService.NotifyInformation( + title: "DirectSong Remix updated", + description: $"Updated to commit {commitSha[..7]}"); + } + else + { + scopedLogger.LogWarning("Updated DirectSong Remix but could not determine commit SHA"); + this.notificationService.NotifyInformation( + title: "DirectSong Remix updated", + description: "Downloaded latest version"); + } + + return true; + } + + /// + /// Syncs the GWDirectSongRemix audio files from the GitHub repository. + /// Downloads any missing files (except readme.txt) to the DirectSong folder. + /// + private async Task SyncRemixAudioFiles(CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + var directSongDir = Path.Combine(Path.GetFullPath(InstallationDirectory), DirectSongSubdir); + var remixAudioDir = Path.Combine(directSongDir, RemixAudioFolder); + + // Ensure the directory exists + Directory.CreateDirectory(remixAudioDir); + + // Get list of files from GitHub + var repoFiles = await this.githubClient.GetDirectoryContents( + RemixRepoOwner, RemixRepoName, RemixAudioFolder, RemixBranch, cancellationToken); + + var filesToDownload = repoFiles + .Where(f => !f.Equals("readme.txt", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (filesToDownload.Count == 0) + { + scopedLogger.LogDebug("No audio files found in GWDirectSongRemix folder"); + return 0; + } + + var downloadedCount = 0; + foreach (var fileName in filesToDownload) + { + var localPath = Path.Combine(remixAudioDir, fileName); + if (File.Exists(localPath)) + { + scopedLogger.LogDebug("File already exists: {FileName}", fileName); + continue; + } + + var repoPath = $"{RemixAudioFolder}/{fileName}"; + scopedLogger.LogDebug("Downloading missing audio file: {FileName}", fileName); + + if (await this.githubClient.DownloadRawFile( + RemixRepoOwner, RemixRepoName, RemixBranch, repoPath, localPath, cancellationToken)) + { + downloadedCount++; + scopedLogger.LogInformation("Downloaded audio file: {FileName}", fileName); + } + else + { + scopedLogger.LogWarning("Failed to download audio file: {FileName}", fileName); + } + } + + if (downloadedCount > 0) + { + this.notificationService.NotifyInformation( + title: "DirectSong Remix audio files synced", + description: $"Downloaded {downloadedCount} new audio file(s)"); + } + + return downloadedCount; + } } diff --git a/Daybreak.Core/Services/Github/GithubClient.cs b/Daybreak.Core/Services/Github/GithubClient.cs index 70def734..5a103c4a 100644 --- a/Daybreak.Core/Services/Github/GithubClient.cs +++ b/Daybreak.Core/Services/Github/GithubClient.cs @@ -14,8 +14,12 @@ internal sealed class GithubClient( private const string RefTagsUrlTemplate = "https://api.github.com/repos/{0}/{1}/git/refs/tags"; private const string ReleasesUrlTemplate = "https://api.github.com/repos/{0}/{1}/releases"; private const string LatestReleaseUrlTemplate = "https://github.com/{0}/{1}/releases/latest"; + private const string RawFileUrlTemplate = "https://raw.githubusercontent.com/{0}/{1}/{2}/{3}"; + private const string CommitsAtomUrlTemplate = "https://github.com/{0}/{1}/commits/{2}.atom"; + private const string ContentsUrlTemplate = "https://api.github.com/repos/{0}/{1}/contents/{2}?ref={3}"; private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan DownloadTimeout = TimeSpan.FromMinutes(5); private readonly IHttpClient httpClient = httpClient.ThrowIfNull(); private readonly ILogger logger = logger.ThrowIfNull(); @@ -120,4 +124,146 @@ internal sealed class GithubClient( return default; } } + + public async Task DownloadRawFile(string owner, string repo, string branch, string filePath, string destinationPath, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + var url = string.Format(RawFileUrlTemplate, owner, repo, branch, filePath); + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(DownloadTimeout); + + scopedLogger.LogDebug("Downloading raw file from {Url}", url); + using var response = await this.httpClient.GetAsync(url, timeoutCts.Token); + if (!response.IsSuccessStatusCode) + { + scopedLogger.LogError("Failed to download raw file. Status code: {StatusCode}", response.StatusCode); + return false; + } + + // Ensure directory exists + var directory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + using var fileStream = File.Create(destinationPath); + await response.Content.CopyToAsync(fileStream, timeoutCts.Token); + + scopedLogger.LogDebug("Successfully downloaded {FilePath} to {DestinationPath}", filePath, destinationPath); + return true; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + scopedLogger.LogWarning("Download from {Url} timed out", url); + return false; + } + catch (Exception e) + { + scopedLogger.LogError(e, "Failed to download raw file from {Url}", url); + return false; + } + } + + public async Task GetLatestCommitSha(string owner, string repo, string branch, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + var url = string.Format(CommitsAtomUrlTemplate, owner, repo, branch); + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(RequestTimeout); + + scopedLogger.LogDebug("Fetching commits atom feed from {Url}", url); + using var response = await this.httpClient.GetAsync(url, timeoutCts.Token); + if (!response.IsSuccessStatusCode) + { + scopedLogger.LogError("Failed to fetch commits atom feed. Status code: {StatusCode}", response.StatusCode); + return null; + } + + var content = await response.Content.ReadAsStringAsync(timeoutCts.Token); + + // Parse the Atom feed to extract the latest commit SHA from the first tag + // Format: tag:github.com,2008:Grit::Commit/{sha} + var idStartIndex = content.IndexOf("tag:github.com,2008:Grit::Commit/", StringComparison.Ordinal); + if (idStartIndex < 0) + { + scopedLogger.LogWarning("Could not find commit ID in atom feed"); + return null; + } + + var shaStart = idStartIndex + "tag:github.com,2008:Grit::Commit/".Length; + var shaEnd = content.IndexOf("", shaStart, StringComparison.Ordinal); + if (shaEnd < 0) + { + scopedLogger.LogWarning("Could not parse commit SHA from atom feed"); + return null; + } + + var sha = content[shaStart..shaEnd]; + scopedLogger.LogDebug("Latest commit SHA: {Sha}", sha); + return sha; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + scopedLogger.LogWarning("Request to {Url} timed out", url); + return null; + } + catch (Exception e) + { + scopedLogger.LogError(e, "Failed to fetch commits from {Url}", url); + return null; + } + } + + public async Task> GetDirectoryContents(string owner, string repo, string path, string branch, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(); + var url = string.Format(ContentsUrlTemplate, owner, repo, path, branch); + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(RequestTimeout); + + scopedLogger.LogDebug("Fetching directory contents from {Url}", url); + using var response = await this.httpClient.GetAsync(url, timeoutCts.Token); + if (!response.IsSuccessStatusCode) + { + scopedLogger.LogError("Failed to fetch directory contents. Status code: {StatusCode}", response.StatusCode); + return []; + } + + var items = await response.Content.ReadFromJsonAsync>(timeoutCts.Token); + if (items is null) + { + scopedLogger.LogWarning("Failed to deserialize directory contents"); + return []; + } + + var files = items + .Where(item => item.IsFile && !string.IsNullOrEmpty(item.Name)) + .Select(item => item.Name!) + .ToList(); + + foreach (var fileName in files) + { + scopedLogger.LogDebug("Found file: {FileName}", fileName); + } + + return files; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + scopedLogger.LogWarning("Request to {Url} timed out", url); + return []; + } + catch (Exception e) + { + scopedLogger.LogError(e, "Failed to fetch directory contents from {Url}", url); + return []; + } + } } diff --git a/Daybreak.Core/Services/Startup/Actions/UpdateDirectSongAction.cs b/Daybreak.Core/Services/Startup/Actions/UpdateDirectSongAction.cs new file mode 100644 index 00000000..41bc3e4e --- /dev/null +++ b/Daybreak.Core/Services/Startup/Actions/UpdateDirectSongAction.cs @@ -0,0 +1,38 @@ +using Daybreak.Shared.Models; +using Daybreak.Shared.Models.Async; +using Daybreak.Shared.Services.DirectSong; +using Microsoft.Extensions.Logging; +using System.Core.Extensions; +using System.Extensions.Core; + +namespace Daybreak.Services.Startup.Actions; + +internal sealed class UpdateDirectSongAction( + IDirectSongService directSongService, + ILogger logger) : StartupActionBase +{ + private readonly IDirectSongService directSongService = directSongService.ThrowIfNull(); + private readonly ILogger logger = logger.ThrowIfNull(); + + public override void ExecuteOnStartup() + { + var scopedLogger = this.logger.CreateScopedLogger(); + var progress = new Progress(); + if (!this.directSongService.IsInstalled) + { + scopedLogger.LogInformation("DirectSong is not installed. Skipping update."); + return; + } + + Task.Factory.StartNew(async () => + { + if (!await this.directSongService.IsUpdateAvailable(CancellationToken.None)) + { + scopedLogger.LogInformation("DirectSong is up to date"); + return; + } + + await this.directSongService.PerformUpdate(CancellationToken.None); + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Current); + } +} diff --git a/Daybreak.Linux/Services/DirectSong/DirectSongRegistrar.cs b/Daybreak.Linux/Services/DirectSong/DirectSongRegistrar.cs index 0e3638ed..9c60db0e 100644 --- a/Daybreak.Linux/Services/DirectSong/DirectSongRegistrar.cs +++ b/Daybreak.Linux/Services/DirectSong/DirectSongRegistrar.cs @@ -1,41 +1,297 @@ using Daybreak.Linux.Services.Wine; +using Daybreak.Shared.Models.Async; using Daybreak.Shared.Services.DirectSong; +using Daybreak.Shared.Services.Downloads; using Daybreak.Shared.Utils; using Microsoft.Extensions.Logging; +using System.Diagnostics; namespace Daybreak.Linux.Services.DirectSong; /// -/// Linux implementation that runs the DirectSong registry editor through Wine. +/// Linux implementation that sets up DirectSong through Wine registry and DLL overrides. +/// Based on: https://github.com/ChthonVII/guildwarslinuxinstallguide#part-10-directsong /// internal sealed class DirectSongRegistrar( IWinePrefixManager winePrefixManager, ILogger logger) : IDirectSongRegistrar { + // Registry paths for DirectSong + // Win64 prefix uses Wow6432Node for 32-bit applications + private const string DirectSongRegistryKeyWin64 = @"HKLM\Software\Wow6432Node\DirectSong"; + private const string DirectSongRegistryKeyWin32 = @"HKLM\Software\DirectSong"; + private const string MusicPathValueName = "MusicPath"; + + // The Revival Pack extracts DirectSong files to a nested DirectSong subfolder + private const string DirectSongSubdir = "DirectSong"; + + // DLL names + private const string WMVCOREDll = "WMVCORE.DLL"; + private const string WMASFDll = "WMASF.DLL"; + private const string DsGuildWarsDll = "ds_GuildWars.dll"; + + // Subdirectory for Linux-specific DLLs (from ChthonVII's repo) + private const string LinuxDllSubdir = "LinuxDlls"; + + // Download URLs for Linux-specific DLLs + private const string GitHubWMVCOREUrl = "https://github.com/ChthonVII/guildwarslinuxinstallguide/raw/main/extras/DirectSongDLLS/WMVCORE.DLL"; + private const string GitHubWMASFUrl = "https://github.com/ChthonVII/guildwarslinuxinstallguide/raw/main/extras/DirectSongDLLS/WMASF.DLL"; + + // Common gstreamer library paths to check + private static readonly string[] GStreamerLibPaths = + [ + // 32-bit paths (for standard Wine) + "/usr/lib/i386-linux-gnu/gstreamer-1.0/libgstlibav.so", + "/usr/lib32/gstreamer-1.0/libgstlibav.so", + "/usr/lib/gstreamer-1.0/libgstlibav.so", + // 64-bit paths (for Wine wow64 mode) + "/usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstlibav.so", + "/usr/lib64/gstreamer-1.0/libgstlibav.so", + // Arch/Manjaro/CachyOS paths + "/usr/lib/gstreamer-1.0/libgstlibav.so", + "/usr/lib32/gstreamer-1.0/libgstlibav.so", + ]; + private readonly IWinePrefixManager winePrefixManager = winePrefixManager; private readonly ILogger logger = logger; public async Task RegisterDirectSongDirectory(string installationDirectory, string registryEditorName, CancellationToken cancellationToken) { - var fullInstallDir = Path.GetFullPath(installationDirectory); - var wineInstallDir = PathUtils.ToWinePath(fullInstallDir); - var exePath = Path.Combine(fullInstallDir, registryEditorName); + // The actual DirectSong files are in the nested DirectSong subfolder + var directSongDir = Path.Combine(Path.GetFullPath(installationDirectory), DirectSongSubdir); + var wineInstallDir = PathUtils.ToWinePath(directSongDir); - this.logger.LogDebug("Running DirectSong registry editor through Wine: {ExePath}. Expecting output: {NativePath} or {WinePath}", exePath, fullInstallDir, wineInstallDir); + this.logger.LogDebug("Registering DirectSong directory via Wine registry. Native path: {NativePath}, Wine path: {WinePath}", + directSongDir, wineInstallDir); - var (output, error, exitCode) = await this.winePrefixManager.LaunchProcess( - exePath, - fullInstallDir, - [], - cancellationToken, - completionChecker: (line, allLines) => - line.Trim().Equals(fullInstallDir, StringComparison.OrdinalIgnoreCase) || - line.Trim().Equals(wineInstallDir, StringComparison.OrdinalIgnoreCase)); + // Wine prefixes are typically 64-bit (win64), so we use the Wow6432Node path + // for 32-bit applications like Guild Wars + var registryKey = DirectSongRegistryKeyWin64; + + var success = await this.winePrefixManager.AddRegistryValue( + registryKey, + MusicPathValueName, + wineInstallDir, + "REG_SZ", + cancellationToken); - var success = output is not null && - (output.Contains(fullInstallDir, StringComparison.OrdinalIgnoreCase) || - output.Contains(wineInstallDir, StringComparison.OrdinalIgnoreCase)); - this.logger.LogDebug("DirectSong registry registration via Wine result: {Success}, ExitCode: {ExitCode}", success, exitCode); + if (!success) + { + this.logger.LogWarning("Failed to set registry in {Key}, trying 32-bit key", registryKey); + // Fallback to 32-bit registry path in case of a 32-bit prefix + success = await this.winePrefixManager.AddRegistryValue( + DirectSongRegistryKeyWin32, + MusicPathValueName, + wineInstallDir, + "REG_SZ", + cancellationToken); + } + + this.logger.LogDebug("DirectSong registry registration result: {Success}", success); return success; } + + public async Task SetupDllOverrides(CancellationToken cancellationToken) + { + this.logger.LogDebug("Setting up DLL overrides for DirectSong (wmvcore, wmasf)"); + + // Set wmvcore to native,builtin - required for WMA decoding + var wmvcoreSuccess = await this.winePrefixManager.SetDllOverride("wmvcore", "native,builtin", cancellationToken); + if (!wmvcoreSuccess) + { + this.logger.LogError("Failed to set wmvcore DLL override"); + return false; + } + + // Set wmasf to native,builtin - required for WMA decoding + var wmasfSuccess = await this.winePrefixManager.SetDllOverride("wmasf", "native,builtin", cancellationToken); + if (!wmasfSuccess) + { + this.logger.LogError("Failed to set wmasf DLL override"); + return false; + } + + this.logger.LogDebug("DLL overrides set successfully"); + return true; + } + + public bool IsGStreamerAvailable() + { + // Check if any of the known gstreamer libav paths exist + foreach (var path in GStreamerLibPaths) + { + if (File.Exists(path)) + { + this.logger.LogDebug("Found gstreamer libav at: {Path}", path); + return true; + } + } + + // Try using gst-inspect to check if libav plugin is available + try + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "gst-inspect-1.0", + Arguments = "libav", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + } + }; + + process.Start(); + process.WaitForExit(5000); + + if (process.ExitCode == 0) + { + this.logger.LogDebug("gst-inspect-1.0 found libav plugin"); + return true; + } + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Failed to run gst-inspect-1.0"); + } + + this.logger.LogWarning("GStreamer libav plugin not found. DirectSong WMA playback may not work."); + return false; + } + + public string GetGStreamerInstallInstructions() + { + return """ + DirectSong requires the GStreamer libav plugin for WMA audio playback. + + Install it using your package manager: + + Debian/Ubuntu (32-bit Wine): + sudo apt-get install gstreamer1.0-libav:i386 + + Debian/Ubuntu (Wine wow64 mode): + sudo apt-get install gstreamer1.0-libav + + Arch/Manjaro/CachyOS: + sudo pacman -S gst-libav lib32-gst-libav + + Fedora: + sudo dnf install gstreamer1-libav.i686 + + After installation, restart Daybreak. + """; + } + + public bool ArePlatformFilesInstalled(string installationDirectory) + { + var fullInstallDir = Path.GetFullPath(installationDirectory); + var directSongDir = Path.Combine(fullInstallDir, DirectSongSubdir); + var linuxDllDir = Path.Combine(fullInstallDir, LinuxDllSubdir); + + // Check for Linux-specific DLLs in the LinuxDlls subdirectory + var wmcorePath = Path.Combine(linuxDllDir, WMVCOREDll); + var wmasfPath = Path.Combine(linuxDllDir, WMASFDll); + // ds_GuildWars.dll comes from the nested DirectSong subdirectory + var dsGuildWarsPath = Path.Combine(directSongDir, DsGuildWarsDll); + + var installed = File.Exists(wmcorePath) && File.Exists(wmasfPath) && File.Exists(dsGuildWarsPath); + this.logger.LogDebug("ArePlatformFilesInstalled: wmcore={WmCore}, wmasf={WmAsf}, dsGW={DsGW}, result={Result}", + File.Exists(wmcorePath), File.Exists(wmasfPath), File.Exists(dsGuildWarsPath), installed); + + return installed; + } + + public async Task SetupPlatformFiles(string installationDirectory, IDownloadService downloadService, IProgress progress, CancellationToken cancellationToken) + { + var fullInstallDir = Path.GetFullPath(installationDirectory); + var linuxDllDir = Path.Combine(fullInstallDir, LinuxDllSubdir); + + // Create the Linux DLL subdirectory + Directory.CreateDirectory(linuxDllDir); + + var wmcorePath = Path.Combine(linuxDllDir, WMVCOREDll); + var wmasfPath = Path.Combine(linuxDllDir, WMASFDll); + + // Download WMVCORE.DLL from ChthonVII's repo + this.logger.LogDebug("Downloading WMVCORE.DLL from GitHub to {Path}", wmcorePath); + if (!await downloadService.DownloadFile(GitHubWMVCOREUrl, wmcorePath, progress, cancellationToken)) + { + this.logger.LogError("Failed to download WMVCORE.DLL from GitHub"); + return false; + } + + // Download WMASF.DLL from ChthonVII's repo + this.logger.LogDebug("Downloading WMASF.DLL from GitHub to {Path}", wmasfPath); + if (!await downloadService.DownloadFile(GitHubWMASFUrl, wmasfPath, progress, cancellationToken)) + { + this.logger.LogError("Failed to download WMASF.DLL from GitHub"); + return false; + } + + this.logger.LogDebug("Platform-specific files downloaded successfully"); + return true; + } + + public void CopyFilesToGuildWars(string installationDirectory, string gwDirectory) + { + var fullInstallDir = Path.GetFullPath(installationDirectory); + var directSongDir = Path.Combine(fullInstallDir, DirectSongSubdir); + var linuxDllDir = Path.Combine(fullInstallDir, LinuxDllSubdir); + + // Source paths - Linux DLLs from ChthonVII's repo + var wmcoreSrc = Path.Combine(linuxDllDir, WMVCOREDll); + var wmasfSrc = Path.Combine(linuxDllDir, WMASFDll); + // ds_GuildWars.dll from the nested DirectSong subdirectory + var dsGuildWarsSrc = Path.Combine(directSongDir, DsGuildWarsDll); + + // Destination paths + var wmcoreDst = Path.Combine(gwDirectory, WMVCOREDll); + var wmasfDst = Path.Combine(gwDirectory, WMASFDll); + var dsGuildWarsDst = Path.Combine(gwDirectory, DsGuildWarsDll); + + if (!File.Exists(wmcoreDst) && File.Exists(wmcoreSrc)) + { + File.Copy(wmcoreSrc, wmcoreDst); + this.logger.LogDebug("Copied {Dll} to {Path}", WMVCOREDll, wmcoreDst); + } + + if (!File.Exists(wmasfDst) && File.Exists(wmasfSrc)) + { + File.Copy(wmasfSrc, wmasfDst); + this.logger.LogDebug("Copied {Dll} to {Path}", WMASFDll, wmasfDst); + } + + if (!File.Exists(dsGuildWarsDst) && File.Exists(dsGuildWarsSrc)) + { + File.Copy(dsGuildWarsSrc, dsGuildWarsDst); + this.logger.LogDebug("Copied {Dll} to {Path}", DsGuildWarsDll, dsGuildWarsDst); + } + } + + public void RemoveFilesFromGuildWars(string gwDirectory) + { + var wmcorePath = Path.Combine(gwDirectory, WMVCOREDll); + var wmasfPath = Path.Combine(gwDirectory, WMASFDll); + var dsGuildWarsPath = Path.Combine(gwDirectory, DsGuildWarsDll); + + if (File.Exists(wmcorePath)) + { + File.Delete(wmcorePath); + this.logger.LogDebug("Removed {Dll} from {Path}", WMVCOREDll, gwDirectory); + } + + if (File.Exists(wmasfPath)) + { + File.Delete(wmasfPath); + this.logger.LogDebug("Removed {Dll} from {Path}", WMASFDll, gwDirectory); + } + + if (File.Exists(dsGuildWarsPath)) + { + File.Delete(dsGuildWarsPath); + this.logger.LogDebug("Removed {Dll} from {Path}", DsGuildWarsDll, gwDirectory); + } + } } diff --git a/Daybreak.Linux/Services/Wine/IWinePrefixManager.cs b/Daybreak.Linux/Services/Wine/IWinePrefixManager.cs index 27599e8e..e04d8df0 100644 --- a/Daybreak.Linux/Services/Wine/IWinePrefixManager.cs +++ b/Daybreak.Linux/Services/Wine/IWinePrefixManager.cs @@ -52,4 +52,24 @@ public interface IWinePrefixManager : IModService string[] arguments, CancellationToken cancellationToken, Func, bool>? completionChecker = null); + + /// + /// Sets a DLL override in the Wine registry. + /// + /// Name of the DLL (without .dll extension). + /// Override mode (e.g., "native,builtin"). + /// Cancellation token. + /// True if successful, false otherwise. + Task SetDllOverride(string dllName, string mode, CancellationToken cancellationToken); + + /// + /// Adds a registry value to the Wine prefix registry using 'wine reg add'. + /// + /// Registry key path (e.g., "HKLM\\Software\\DirectSong"). + /// Name of the value to set. + /// The value data. + /// Registry value type (e.g., "REG_SZ" for string). + /// Cancellation token. + /// True if successful, false otherwise. + Task AddRegistryValue(string keyPath, string valueName, string value, string valueType, CancellationToken cancellationToken); } diff --git a/Daybreak.Linux/Services/Wine/WinePrefixManager.cs b/Daybreak.Linux/Services/Wine/WinePrefixManager.cs index 7d191bd4..7e31a9df 100644 --- a/Daybreak.Linux/Services/Wine/WinePrefixManager.cs +++ b/Daybreak.Linux/Services/Wine/WinePrefixManager.cs @@ -482,7 +482,7 @@ public sealed class WinePrefixManager( /// Override mode (e.g., "native,builtin"). /// Cancellation token. /// True if successful, false otherwise. - private async Task SetDllOverride(string dllName, string mode, CancellationToken cancellationToken) + public async Task SetDllOverride(string dllName, string mode, CancellationToken cancellationToken) { try { @@ -519,4 +519,51 @@ public sealed class WinePrefixManager( return false; } } + + /// + /// Adds a registry value to the Wine prefix registry using 'wine reg add'. + /// + /// Registry key path (e.g., "HKLM\\Software\\DirectSong"). + /// Name of the value to set. + /// The value data. + /// Registry value type (e.g., "REG_SZ" for string). + /// Cancellation token. + /// True if successful, false otherwise. + public async Task AddRegistryValue(string keyPath, string valueName, string value, string valueType, CancellationToken cancellationToken) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = WineExecutable, + Arguments = $"reg add \"{keyPath}\" /v {valueName} /t {valueType} /d \"{value}\" /f", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + startInfo.Environment["WINEPREFIX"] = this.winePrefixPath; + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode != 0) + { + var error = await process.StandardError.ReadToEndAsync(cancellationToken); + this.logger.LogError("Failed to add registry value {Key}\\{Value}. Exit code: {ExitCode}, Error: {Error}", + keyPath, valueName, process.ExitCode, error); + return false; + } + + this.logger.LogDebug("Added registry value: {Key}\\{Value}={Data}", keyPath, valueName, value); + return true; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Exception while adding registry value {Key}\\{Value}", keyPath, valueName); + return false; + } + } } diff --git a/Daybreak.Shared/Models/Github/GithubContentItem.cs b/Daybreak.Shared/Models/Github/GithubContentItem.cs new file mode 100644 index 00000000..585308f3 --- /dev/null +++ b/Daybreak.Shared/Models/Github/GithubContentItem.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace Daybreak.Shared.Models.Github; + +public sealed class GithubContentItem +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("size")] + public long Size { get; set; } + + [JsonPropertyName("download_url")] + public string? DownloadUrl { get; set; } + + public bool IsFile => this.Type == "file"; + + public bool IsDirectory => this.Type == "dir"; +} diff --git a/Daybreak.Shared/Services/DirectSong/IDirectSongRegistrar.cs b/Daybreak.Shared/Services/DirectSong/IDirectSongRegistrar.cs index 9699acf0..a8850477 100644 --- a/Daybreak.Shared/Services/DirectSong/IDirectSongRegistrar.cs +++ b/Daybreak.Shared/Services/DirectSong/IDirectSongRegistrar.cs @@ -1,10 +1,66 @@ +using Daybreak.Shared.Models.Async; +using Daybreak.Shared.Services.Downloads; + namespace Daybreak.Shared.Services.DirectSong; /// -/// Runs the DirectSong registry editor executable to register the DirectSong installation directory. -/// On Windows this runs the exe natively; on Linux it runs through Wine. +/// Handles DirectSong registration including registry entries, DLL overrides, and platform-specific file management. +/// On Windows this runs the exe natively; on Linux it sets up Wine registry and DLL overrides. /// public interface IDirectSongRegistrar { + /// + /// Registers the DirectSong directory in the registry. + /// On Windows: Runs the registry editor executable. + /// On Linux: Sets the MusicPath registry entry via Wine. + /// Task RegisterDirectSongDirectory(string installationDirectory, string registryEditorName, CancellationToken cancellationToken); + + /// + /// Sets up DLL overrides required for DirectSong. + /// On Windows: No-op (returns true). + /// On Linux: Sets wmvcore and wmasf to native,builtin. + /// + Task SetupDllOverrides(CancellationToken cancellationToken); + + /// + /// Checks if the gstreamer library with libav plugin is available. + /// On Windows: Always returns true. + /// On Linux: Checks for gstreamer1.0-libav or equivalent. + /// + /// True if available, false if missing (user needs to install it). + bool IsGStreamerAvailable(); + + /// + /// Gets a user-friendly message about how to install gstreamer. + /// + string GetGStreamerInstallInstructions(); + + /// + /// Checks if platform-specific files are installed. + /// On Windows: Checks for WMVCORE.DLL and ds_GuildWars.dll in the installation directory. + /// On Linux: Checks for WMVCORE.DLL and WMASF.DLL in the Linux subdirectory + ds_GuildWars.dll. + /// + bool ArePlatformFilesInstalled(string installationDirectory); + + /// + /// Downloads and sets up platform-specific files. + /// On Windows: No-op (files come from DirectSong.7z). + /// On Linux: Downloads WMVCORE.DLL and WMASF.DLL from GitHub to a Linux subdirectory. + /// + Task SetupPlatformFiles(string installationDirectory, IDownloadService downloadService, IProgress progress, CancellationToken cancellationToken); + + /// + /// Copies DirectSong DLLs to the Guild Wars directory. + /// On Windows: Copies WMVCORE.DLL and ds_GuildWars.dll. + /// On Linux: Copies WMVCORE.DLL, WMASF.DLL from Linux subdir + ds_GuildWars.dll. + /// + void CopyFilesToGuildWars(string installationDirectory, string gwDirectory); + + /// + /// Removes DirectSong DLLs from the Guild Wars directory. + /// On Windows: Removes WMVCORE.DLL and ds_GuildWars.dll. + /// On Linux: Removes WMVCORE.DLL, WMASF.DLL, and ds_GuildWars.dll. + /// + void RemoveFilesFromGuildWars(string gwDirectory); } diff --git a/Daybreak.Shared/Services/Github/IGithubClient.cs b/Daybreak.Shared/Services/Github/IGithubClient.cs index 0ebbe6af..2cfca17e 100644 --- a/Daybreak.Shared/Services/Github/IGithubClient.cs +++ b/Daybreak.Shared/Services/Github/IGithubClient.cs @@ -22,4 +22,40 @@ public interface IGithubClient /// Gets the latest version by following the /releases/latest redirect URL. /// Task GetLatestVersionFromRedirect(string owner, string repo, CancellationToken cancellationToken); + + /// + /// Downloads a raw file from a GitHub repository without using the API. + /// Uses raw.githubusercontent.com which has no rate limiting. + /// + /// Repository owner + /// Repository name + /// Branch name (e.g., "main") + /// Path to the file in the repository + /// Local path to save the file + /// Cancellation token + /// True if download succeeded, false otherwise + Task DownloadRawFile(string owner, string repo, string branch, string filePath, string destinationPath, CancellationToken cancellationToken); + + /// + /// Gets the latest commit SHA for a specific file path from a GitHub repository. + /// Uses the Atom feed to avoid API rate limiting. + /// + /// Repository owner + /// Repository name + /// Branch name (e.g., "main") + /// Cancellation token + /// The commit SHA or null if failed + Task GetLatestCommitSha(string owner, string repo, string branch, CancellationToken cancellationToken); + + /// + /// Gets the list of files in a directory from a GitHub repository. + /// Note: Uses GitHub API which has rate limits (60 requests/hour unauthenticated). + /// + /// Repository owner + /// Repository name + /// Directory path in the repository + /// Branch name (e.g., "main") + /// Cancellation token + /// List of file names in the directory, or empty if failed + Task> GetDirectoryContents(string owner, string repo, string path, string branch, CancellationToken cancellationToken); } diff --git a/Daybreak.Windows/Services/DirectSong/DirectSongRegistrar.cs b/Daybreak.Windows/Services/DirectSong/DirectSongRegistrar.cs index 54bf8647..42658399 100644 --- a/Daybreak.Windows/Services/DirectSong/DirectSongRegistrar.cs +++ b/Daybreak.Windows/Services/DirectSong/DirectSongRegistrar.cs @@ -1,56 +1,126 @@ +using Daybreak.Shared.Models.Async; using Daybreak.Shared.Services.DirectSong; +using Daybreak.Shared.Services.Downloads; using Microsoft.Extensions.Logging; -using System.Diagnostics; +using Microsoft.Win32; namespace Daybreak.Windows.Services.DirectSong; /// -/// Windows implementation that runs the DirectSong registry editor natively. +/// Windows implementation that registers DirectSong via the Windows registry. +/// Uses HKEY_CURRENT_USER to avoid requiring administrator privileges. /// internal sealed class DirectSongRegistrar( ILogger logger) : IDirectSongRegistrar { + // The Revival Pack extracts DirectSong files to a nested DirectSong subfolder + private const string DirectSongSubdir = "DirectSong"; + + // Registry paths for DirectSong + // Using HKCU instead of HKLM to avoid requiring admin privileges + private const string DirectSongRegistryKey = @"Software\DirectSong"; + private const string MusicPathValueName = "MusicPath"; + private const string DsGuildWarsDll = "ds_GuildWars.dll"; + private readonly ILogger logger = logger; - public async Task RegisterDirectSongDirectory(string installationDirectory, string registryEditorName, CancellationToken cancellationToken) + public Task RegisterDirectSongDirectory(string installationDirectory, string registryEditorName, CancellationToken cancellationToken) + { + // The actual DirectSong files are in the nested DirectSong subfolder + var directSongDir = Path.Combine(Path.GetFullPath(installationDirectory), DirectSongSubdir); + + try + { + // Try HKEY_CURRENT_USER first (no admin required) + using var hkcuKey = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(DirectSongRegistryKey); + if (hkcuKey is not null) + { + hkcuKey.SetValue(MusicPathValueName, directSongDir); + this.logger.LogDebug("Set DirectSong registry in HKCU: {Path}", directSongDir); + return Task.FromResult(true); + } + + this.logger.LogWarning("Failed to create registry key in HKCU"); + + // Fallback to HKEY_LOCAL_MACHINE (requires admin, but try anyway) + try + { + using var hklmKey = Microsoft.Win32.Registry.LocalMachine.CreateSubKey(DirectSongRegistryKey); + if (hklmKey is not null) + { + hklmKey.SetValue(MusicPathValueName, directSongDir); + this.logger.LogDebug("Set DirectSong registry in HKLM: {Path}", directSongDir); + return Task.FromResult(true); + } + } + catch (UnauthorizedAccessException) + { + this.logger.LogWarning("Cannot write to HKLM without administrator privileges"); + } + + return Task.FromResult(false); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to set DirectSong registry"); + return Task.FromResult(false); + } + } + + public Task SetupDllOverrides(CancellationToken cancellationToken) + { + // On Windows, no DLL overrides are needed + return Task.FromResult(true); + } + + public bool IsGStreamerAvailable() + { + // On Windows, gstreamer is not required + return true; + } + + public string GetGStreamerInstallInstructions() + { + return string.Empty; + } + + public bool ArePlatformFilesInstalled(string installationDirectory) { var fullInstallDir = Path.GetFullPath(installationDirectory); - using var process = new Process() - { - StartInfo = new ProcessStartInfo - { - FileName = Path.Combine(fullInstallDir, registryEditorName), - WorkingDirectory = fullInstallDir, - CreateNoWindow = true, - RedirectStandardOutput = true, - UseShellExecute = false - }, - EnableRaisingEvents = true - }; + var directSongDir = Path.Combine(fullInstallDir, DirectSongSubdir); + var dsGuildWarsPath = Path.Combine(directSongDir, DsGuildWarsDll); - var success = false; - process.OutputDataReceived += (s, e) => - { - if (e.Data == fullInstallDir) - { - success = true; - } - }; + return File.Exists(dsGuildWarsPath); + } - process.Start(); - process.BeginOutputReadLine(); - while (!process.HasExited && !success) + public Task SetupPlatformFiles(string installationDirectory, IDownloadService downloadService, IProgress? progress, CancellationToken cancellationToken) + { + // On Windows, all files come from the DirectSong.7z archive - nothing extra to download + return Task.FromResult(true); + } + + public void CopyFilesToGuildWars(string installationDirectory, string gwDirectory) + { + var fullInstallDir = Path.GetFullPath(installationDirectory); + var directSongDir = Path.Combine(fullInstallDir, DirectSongSubdir); + var dsGuildWarsSrc = Path.Combine(directSongDir, DsGuildWarsDll); + var dsGuildWarsDst = Path.Combine(gwDirectory, DsGuildWarsDll); + + if (!File.Exists(dsGuildWarsDst) && File.Exists(dsGuildWarsSrc)) { - await Task.Delay(1000, cancellationToken); + File.Copy(dsGuildWarsSrc, dsGuildWarsDst); + this.logger.LogDebug("Copied {Dll} to {Path}", DsGuildWarsDll, dsGuildWarsDst); } + } - process.CancelOutputRead(); - if (!process.HasExited) + public void RemoveFilesFromGuildWars(string gwDirectory) + { + var dsGuildWarsPath = Path.Combine(gwDirectory, DsGuildWarsDll); + + if (File.Exists(dsGuildWarsPath)) { - process.Close(); + File.Delete(dsGuildWarsPath); + this.logger.LogDebug("Removed {Dll} from {Path}", DsGuildWarsDll, gwDirectory); } - - this.logger.LogDebug("DirectSong registry registration result: {Success}", success); - return success; } } diff --git a/Directory.Packages.props b/Directory.Packages.props index fb39c1fb..c96a8e3b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ +