From 78a32813484b0f4ef8ccc802955b22c283c48689 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 12 Feb 2026 10:36:30 +0000 Subject: [PATCH 1/6] Bump version to 0.9.10.0 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0985674b..56527e0c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ $(NoWarn);IL2104;IL3053;IL3000;IL3002;NU1701;CS0108 - 0.9.9.108 + 0.9.10.0 enable enable From 1bbf2fb815d2aee41257b277d1e04fe8e20a5a0d Mon Sep 17 00:00:00 2001 From: Macocian Alexandru Victor Date: Fri, 13 Feb 2026 04:49:53 -0800 Subject: [PATCH 2/6] DirectSong improvements (#1429) (Closes #1189) * DirectSong improvements * Use ChthonVIIs directsong remix * DirectSong fetches remix mp3s * Stop using ds executable to register entries on Windows * DirectSong don't rely on admin rights * Windows fixes --- .../Configuration/ProjectConfiguration.cs | 1 + Daybreak.Core/Daybreak.Core.csproj | 1 + .../Services/DirectSong/DirectSongService.cs | 331 +++++++++++++++--- Daybreak.Core/Services/Github/GithubClient.cs | 146 ++++++++ .../Startup/Actions/UpdateDirectSongAction.cs | 38 ++ .../DirectSong/DirectSongRegistrar.cs | 296 ++++++++++++++-- .../Services/Wine/IWinePrefixManager.cs | 20 ++ .../Services/Wine/WinePrefixManager.cs | 49 ++- .../Models/Github/GithubContentItem.cs | 25 ++ .../DirectSong/IDirectSongRegistrar.cs | 60 +++- .../Services/Github/IGithubClient.cs | 36 ++ .../DirectSong/DirectSongRegistrar.cs | 130 +++++-- Directory.Packages.props | 1 + 13 files changed, 1036 insertions(+), 98 deletions(-) create mode 100644 Daybreak.Core/Services/Startup/Actions/UpdateDirectSongAction.cs create mode 100644 Daybreak.Shared/Models/Github/GithubContentItem.cs 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 override void RegisterStartupActions(IStartupActionProducer startupAction 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 @@ public bool IsEnabled } 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 @@ public Task OnCustomManagement(CancellationToken cancellationToken) public IEnumerable GetCustomArguments() => []; - public Task IsUpdateAvailable(CancellationToken cancellationToken) => Task.FromResult(false); + public async Task IsUpdateAvailable(CancellationToken cancellationToken) + { + return await this.IsRemixUpdateAvailable(cancellationToken); + } - public Task PerformUpdate(CancellationToken cancellationToken) + public async Task PerformUpdate(CancellationToken cancellationToken) { - throw new NotImplementedException("DirectSong mod does not support manual updates"); + if (!this.IsInstalled) + { + return false; + } + + return await this.UpdateRemixPlaylist(cancellationToken); } public Task ShouldRunAgain(GuildWarsRunningContext guildWarsRunningContext, CancellationToken cancellationToken) => Task.FromResult(false); @@ -135,17 +153,8 @@ public Task OnGuildWarsStarting(GuildWarsStartingContext guildWarsStartingContex 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 @@ public Task OnGuildWarsStartingDisabled(GuildWarsStartingDisabledContext guildWa { 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 @@ private async Task SetupDirectSongInternal(IProgress progr 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 @@ private async Task SetupDirectSongInternal(IProgress progr 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 @@ private async Task SetupDirectSongInternal(IProgress progr 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 @@ private async Task SetupDirectSongInternal(IProgress progr 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 @@ public async Task> GetReleases(string owner, string r 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); - - this.logger.LogDebug("Running DirectSong registry editor through Wine: {ExePath}. Expecting output: {NativePath} or {WinePath}", exePath, fullInstallDir, 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)); - - 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); + // 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("Registering DirectSong directory via Wine registry. Native path: {NativePath}, Wine path: {WinePath}", + directSongDir, wineInstallDir); + + // 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); + + 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 @@ CancellationToken cancellationToken /// 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 @@ private async Task SetDllOverride(string dllName, string mode, Cancellatio 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) { - var fullInstallDir = Path.GetFullPath(installationDirectory); - using var process = new Process() + // The actual DirectSong files are in the nested DirectSong subfolder + var directSongDir = Path.Combine(Path.GetFullPath(installationDirectory), DirectSongSubdir); + + try { - StartInfo = new ProcessStartInfo + // Try HKEY_CURRENT_USER first (no admin required) + using var hkcuKey = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(DirectSongRegistryKey); + if (hkcuKey is not null) { - FileName = Path.Combine(fullInstallDir, registryEditorName), - WorkingDirectory = fullInstallDir, - CreateNoWindow = true, - RedirectStandardOutput = true, - UseShellExecute = false - }, - EnableRaisingEvents = true - }; - - var success = false; - process.OutputDataReceived += (s, e) => - { - if (e.Data == fullInstallDir) + 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) { - success = true; + this.logger.LogWarning("Cannot write to HKLM without administrator privileges"); } - }; - process.Start(); - process.BeginOutputReadLine(); - while (!process.HasExited && !success) + return Task.FromResult(false); + } + catch (Exception ex) { - await Task.Delay(1000, cancellationToken); + 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); + var directSongDir = Path.Combine(fullInstallDir, DirectSongSubdir); + var dsGuildWarsPath = Path.Combine(directSongDir, DsGuildWarsDll); + + return File.Exists(dsGuildWarsPath); + } + + 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); + } - process.CancelOutputRead(); - if (!process.HasExited) + 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)) { - process.Close(); + File.Copy(dsGuildWarsSrc, dsGuildWarsDst); + this.logger.LogDebug("Copied {Dll} to {Path}", DsGuildWarsDll, dsGuildWarsDst); } + } - this.logger.LogDebug("DirectSong registry registration result: {Success}", success); - return success; + public void RemoveFilesFromGuildWars(string gwDirectory) + { + var dsGuildWarsPath = Path.Combine(gwDirectory, DsGuildWarsDll); + + if (File.Exists(dsGuildWarsPath)) + { + File.Delete(dsGuildWarsPath); + this.logger.LogDebug("Removed {Dll} from {Path}", DsGuildWarsDll, gwDirectory); + } } } 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 @@ + From 5de1880c8984c30cbf0f82dcf2caf86d6478f7da Mon Sep 17 00:00:00 2001 From: Macocian Alexandru Victor Date: Fri, 13 Feb 2026 04:58:03 -0800 Subject: [PATCH 3/6] Update gwmarket url (Closes #1430) (#1431) --- Daybreak.Core/Views/GuildWarsMarketView.razor.cs | 2 +- Daybreak.Windows/Services/DirectSong/DirectSongRegistrar.cs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Daybreak.Core/Views/GuildWarsMarketView.razor.cs b/Daybreak.Core/Views/GuildWarsMarketView.razor.cs index 4d5e7cbc..e378b6c1 100644 --- a/Daybreak.Core/Views/GuildWarsMarketView.razor.cs +++ b/Daybreak.Core/Views/GuildWarsMarketView.razor.cs @@ -4,5 +4,5 @@ namespace Daybreak.Views; public sealed class GuildWarsMarketViewModel : ViewModelBase { - public string GwMarketUrl { get; } = "https://v2.gwmarket.net/"; + public string GwMarketUrl { get; } = "https://gwmarket.net/"; } diff --git a/Daybreak.Windows/Services/DirectSong/DirectSongRegistrar.cs b/Daybreak.Windows/Services/DirectSong/DirectSongRegistrar.cs index 42658399..2e327e6f 100644 --- a/Daybreak.Windows/Services/DirectSong/DirectSongRegistrar.cs +++ b/Daybreak.Windows/Services/DirectSong/DirectSongRegistrar.cs @@ -2,7 +2,6 @@ using Daybreak.Shared.Services.DirectSong; using Daybreak.Shared.Services.Downloads; using Microsoft.Extensions.Logging; -using Microsoft.Win32; namespace Daybreak.Windows.Services.DirectSong; From 5a9e95d4fc8a996772021b844ef6d2f3c5d0e12c Mon Sep 17 00:00:00 2001 From: Macocian Alexandru Victor Date: Fri, 13 Feb 2026 05:18:02 -0800 Subject: [PATCH 4/6] Update wiki (Closes #1428) (#1432) --- Daybreak.wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Daybreak.wiki b/Daybreak.wiki index 238e9efc..6d0535ee 160000 --- a/Daybreak.wiki +++ b/Daybreak.wiki @@ -1 +1 @@ -Subproject commit 238e9efc5c6b44614be35fa3bb58c50ef39b9a1a +Subproject commit 6d0535ee2c1e54d2576bbece9eda43194e1b6908 From fbb9da09e2a89165b4dbbf3bb43cbd8a365d59a3 Mon Sep 17 00:00:00 2001 From: Macocian Alexandru Victor Date: Fri, 13 Feb 2026 06:14:54 -0800 Subject: [PATCH 5/6] Update dependencies (#1433) Co-authored-by: Alexandru Macocian --- .../Services/TarGz/TarGzExtractor.cs | 10 +--- Directory.Packages.props | 58 +++++++++---------- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/Daybreak.Core/Services/TarGz/TarGzExtractor.cs b/Daybreak.Core/Services/TarGz/TarGzExtractor.cs index 45fa3d4c..013bd7eb 100644 --- a/Daybreak.Core/Services/TarGz/TarGzExtractor.cs +++ b/Daybreak.Core/Services/TarGz/TarGzExtractor.cs @@ -44,7 +44,7 @@ private bool ExtractToDirectoryInternal( // Use ReaderFactory which properly handles nested archives like .tar.gz using var stream = File.OpenRead(sourceFile); - using var reader = ReaderFactory.Open(stream); + using var reader = ReaderFactory.OpenReader(stream, new ReaderOptions { ExtractFullPath = true, Overwrite = true }); var processedEntries = 0; @@ -68,11 +68,7 @@ private bool ExtractToDirectoryInternal( progressTracker(0.5, entryKey); // Extract the entry - reader.WriteEntryToDirectory(destinationDirectory, new ExtractionOptions - { - ExtractFullPath = true, - Overwrite = true - }); + reader.WriteEntryToDirectory(destinationDirectory); processedEntries++; } @@ -97,4 +93,4 @@ private bool ExtractToDirectoryInternal( return false; } } -} \ No newline at end of file +} diff --git a/Directory.Packages.props b/Directory.Packages.props index c96a8e3b..6c86c847 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,7 +6,7 @@ - + @@ -15,36 +15,36 @@ - - + + - - - - - - - - + + + + + + + + - - + + - - - - + + + + - + @@ -53,24 +53,24 @@ - - - - - + + + + + - - - + + + - + - + - + \ No newline at end of file From 33c852e82bd71502302e4695b6e1d6bf0a2bf408 Mon Sep 17 00:00:00 2001 From: Macocian Alexandru Victor Date: Fri, 13 Feb 2026 06:19:26 -0800 Subject: [PATCH 6/6] Fix compare version script (#1435) --- Scripts/CompareVersions.ps1 | 42 +++++++++++++++---------------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/Scripts/CompareVersions.ps1 b/Scripts/CompareVersions.ps1 index dddbfe78..09ed3715 100644 --- a/Scripts/CompareVersions.ps1 +++ b/Scripts/CompareVersions.ps1 @@ -15,34 +15,26 @@ if ($lastVersion.StartsWith("v")){ $currentVersionTokens = $currentVersion.Split('.') $lastVersionTokens = $lastVersion.Split('.') -if ($currentVersionTokens.Length -eq $lastVersionTokens.Length) + +# Compare versions segment by segment +$maxLength = [Math]::Max($currentVersionTokens.Length, $lastVersionTokens.Length) +for($i = 0; $i -lt $maxLength; $i++) { - for($i = 0 ; $i -lt $currentVersionTokens.Length; $i++) - { - $currentVersionToken = [int]$currentVersionTokens[$i] - $lastVersionToken = [int]$lastVersionTokens[$i] - if ($currentVersionToken -lt $lastVersionToken){ - throw "Version is not incremented. Current version " + $currentVersion + ". Last version " + $lastVersion - } + # Treat missing segments as 0 + $currentVersionToken = if ($i -lt $currentVersionTokens.Length) { [int]$currentVersionTokens[$i] } else { 0 } + $lastVersionToken = if ($i -lt $lastVersionTokens.Length) { [int]$lastVersionTokens[$i] } else { 0 } + + if ($currentVersionToken -gt $lastVersionToken) { + # Current version is greater, we're done + Write-Host "Version has been incremented" + exit 0 } - - if ($currentVersionToken -le $lastVersionToken){ + elseif ($currentVersionToken -lt $lastVersionToken) { + # Current version is less, fail throw "Version is not incremented. Current version " + $currentVersion + ". Last version " + $lastVersion } - - Write-Host "Version has been incremented" + # If equal, continue to next segment } -elseif ($currentVersionTokens.Length -gt $lastVersionTokens.Length){ - if ($currentVersionTokens[$lastVersionTokens.Length - 1] -lt $lastVersionTokens[$lastVersionTokens.Length - 1]){ - throw "Version is not incremented. Current version " + $currentVersion + ". Last version " + $lastVersion - } - - Write-Host "Version has been incremented" -} -elseif ($currentVersionTokens.Length -lt $lastVersionTokens.Length){ - if ($currentVersionTokens[$currentVersionTokens.Length - 1] -le $lastVersionTokens[$currentVersionTokens.Length - 1]){ - throw "Version is not incremented. Current version " + $currentVersion + ". Last version " + $lastVersion - } - Write-Host "Version has been incremented" -} \ No newline at end of file +# All segments are equal - version is not incremented +throw "Version is not incremented. Current version " + $currentVersion + ". Last version " + $lastVersion \ No newline at end of file