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
This commit is contained in:
2026-02-13 04:49:53 -08:00
parent 4a6eccca24
commit 9e3fa738bb
13 changed files with 1037 additions and 99 deletions
@@ -318,6 +318,7 @@ public class ProjectConfiguration : PluginConfigurationBase
startupActionProducer.RegisterAction<CredentialsOptionsMigrator>();
startupActionProducer.RegisterAction<UpdateToolboxAction>();
startupActionProducer.RegisterAction<UpdateGuildWarsExecutable>();
startupActionProducer.RegisterAction<UpdateDirectSongAction>();
}
public override void RegisterPostUpdateActions(
+1
View File
@@ -54,6 +54,7 @@
<PackageReference Include="DiffPlex" />
<PackageReference Include="HtmlAgilityPack" />
<PackageReference Include="ini-parser-netstandard" />
<PackageReference Include="MegaApiClient" />
<PackageReference Include="Microsoft.CorrelationVector" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
@@ -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<DirectSongOptions> options,
ILogger<DirectSongService> 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<DirectSongOptions> options = options.ThrowIfNull();
private readonly ILogger<DirectSongService> 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<bool> PerformUninstallation(CancellationToken cancellationToken)
{
@@ -111,11 +121,19 @@ internal sealed class DirectSongService(
public IEnumerable<string> GetCustomArguments() => [];
public Task<bool> IsUpdateAvailable(CancellationToken cancellationToken) => Task.FromResult(false);
public Task<bool> PerformUpdate(CancellationToken cancellationToken)
public async Task<bool> IsUpdateAvailable(CancellationToken cancellationToken)
{
throw new NotImplementedException("DirectSong mod does not support manual updates");
return await this.IsRemixUpdateAvailable(cancellationToken);
}
public async Task<bool> PerformUpdate(CancellationToken cancellationToken)
{
if (!this.IsInstalled)
{
return false;
}
return await this.UpdateRemixPlaylist(cancellationToken);
}
public Task<bool> 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<LaunchView>("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<double>(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;
}
/// <summary>
/// Gets the currently installed DirectSong Remix commit SHA from the version file.
/// </summary>
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;
}
}
/// <summary>
/// Checks if a newer version of the DirectSong Remix playlist is available.
/// </summary>
private async Task<bool> 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;
}
/// <summary>
/// Updates the DirectSong Remix playlist to the latest version.
/// </summary>
private async Task<bool> 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;
}
/// <summary>
/// Syncs the GWDirectSongRemix audio files from the GitHub repository.
/// Downloads any missing files (except readme.txt) to the DirectSong folder.
/// </summary>
private async Task<int> 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;
}
}
@@ -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<GithubClient> httpClient = httpClient.ThrowIfNull();
private readonly ILogger<GithubClient> logger = logger.ThrowIfNull();
@@ -120,4 +124,146 @@ internal sealed class GithubClient(
return default;
}
}
public async Task<bool> 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<string?> 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 <id> tag
// Format: tag:github.com,2008:Grit::Commit/{sha}
var idStartIndex = content.IndexOf("<id>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 + "<id>tag:github.com,2008:Grit::Commit/".Length;
var shaEnd = content.IndexOf("</id>", 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<IEnumerable<string>> 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<List<GithubContentItem>>(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 [];
}
}
}
@@ -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<UpdateUModAction> logger) : StartupActionBase
{
private readonly IDirectSongService directSongService = directSongService.ThrowIfNull();
private readonly ILogger<UpdateUModAction> logger = logger.ThrowIfNull();
public override void ExecuteOnStartup()
{
var scopedLogger = this.logger.CreateScopedLogger();
var progress = new Progress<ProgressUpdate>();
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);
}
}
@@ -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;
/// <summary>
/// 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
/// </summary>
internal sealed class DirectSongRegistrar(
IWinePrefixManager winePrefixManager,
ILogger<DirectSongRegistrar> 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<DirectSongRegistrar> logger = logger;
public async Task<bool> 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<bool> 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<bool> SetupPlatformFiles(string installationDirectory, IDownloadService downloadService, IProgress<ProgressUpdate> 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);
}
}
}
@@ -52,4 +52,24 @@ public interface IWinePrefixManager : IModService
string[] arguments,
CancellationToken cancellationToken,
Func<string, IReadOnlyList<string>, bool>? completionChecker = null);
/// <summary>
/// Sets a DLL override in the Wine registry.
/// </summary>
/// <param name="dllName">Name of the DLL (without .dll extension).</param>
/// <param name="mode">Override mode (e.g., "native,builtin").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if successful, false otherwise.</returns>
Task<bool> SetDllOverride(string dllName, string mode, CancellationToken cancellationToken);
/// <summary>
/// Adds a registry value to the Wine prefix registry using 'wine reg add'.
/// </summary>
/// <param name="keyPath">Registry key path (e.g., "HKLM\\Software\\DirectSong").</param>
/// <param name="valueName">Name of the value to set.</param>
/// <param name="value">The value data.</param>
/// <param name="valueType">Registry value type (e.g., "REG_SZ" for string).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if successful, false otherwise.</returns>
Task<bool> AddRegistryValue(string keyPath, string valueName, string value, string valueType, CancellationToken cancellationToken);
}
@@ -482,7 +482,7 @@ public sealed class WinePrefixManager(
/// <param name="mode">Override mode (e.g., "native,builtin").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if successful, false otherwise.</returns>
private async Task<bool> SetDllOverride(string dllName, string mode, CancellationToken cancellationToken)
public async Task<bool> SetDllOverride(string dllName, string mode, CancellationToken cancellationToken)
{
try
{
@@ -519,4 +519,51 @@ public sealed class WinePrefixManager(
return false;
}
}
/// <summary>
/// Adds a registry value to the Wine prefix registry using 'wine reg add'.
/// </summary>
/// <param name="keyPath">Registry key path (e.g., "HKLM\\Software\\DirectSong").</param>
/// <param name="valueName">Name of the value to set.</param>
/// <param name="value">The value data.</param>
/// <param name="valueType">Registry value type (e.g., "REG_SZ" for string).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if successful, false otherwise.</returns>
public async Task<bool> 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;
}
}
}
@@ -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";
}
@@ -1,10 +1,66 @@
using Daybreak.Shared.Models.Async;
using Daybreak.Shared.Services.Downloads;
namespace Daybreak.Shared.Services.DirectSong;
/// <summary>
/// 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.
/// </summary>
public interface IDirectSongRegistrar
{
/// <summary>
/// Registers the DirectSong directory in the registry.
/// On Windows: Runs the registry editor executable.
/// On Linux: Sets the MusicPath registry entry via Wine.
/// </summary>
Task<bool> RegisterDirectSongDirectory(string installationDirectory, string registryEditorName, CancellationToken cancellationToken);
/// <summary>
/// Sets up DLL overrides required for DirectSong.
/// On Windows: No-op (returns true).
/// On Linux: Sets wmvcore and wmasf to native,builtin.
/// </summary>
Task<bool> SetupDllOverrides(CancellationToken cancellationToken);
/// <summary>
/// Checks if the gstreamer library with libav plugin is available.
/// On Windows: Always returns true.
/// On Linux: Checks for gstreamer1.0-libav or equivalent.
/// </summary>
/// <returns>True if available, false if missing (user needs to install it).</returns>
bool IsGStreamerAvailable();
/// <summary>
/// Gets a user-friendly message about how to install gstreamer.
/// </summary>
string GetGStreamerInstallInstructions();
/// <summary>
/// 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.
/// </summary>
bool ArePlatformFilesInstalled(string installationDirectory);
/// <summary>
/// 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.
/// </summary>
Task<bool> SetupPlatformFiles(string installationDirectory, IDownloadService downloadService, IProgress<ProgressUpdate> progress, CancellationToken cancellationToken);
/// <summary>
/// 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.
/// </summary>
void CopyFilesToGuildWars(string installationDirectory, string gwDirectory);
/// <summary>
/// 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.
/// </summary>
void RemoveFilesFromGuildWars(string gwDirectory);
}
@@ -22,4 +22,40 @@ public interface IGithubClient
/// Gets the latest version by following the /releases/latest redirect URL.
/// </summary>
Task<Version?> GetLatestVersionFromRedirect(string owner, string repo, CancellationToken cancellationToken);
/// <summary>
/// Downloads a raw file from a GitHub repository without using the API.
/// Uses raw.githubusercontent.com which has no rate limiting.
/// </summary>
/// <param name="owner">Repository owner</param>
/// <param name="repo">Repository name</param>
/// <param name="branch">Branch name (e.g., "main")</param>
/// <param name="filePath">Path to the file in the repository</param>
/// <param name="destinationPath">Local path to save the file</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if download succeeded, false otherwise</returns>
Task<bool> DownloadRawFile(string owner, string repo, string branch, string filePath, string destinationPath, CancellationToken cancellationToken);
/// <summary>
/// Gets the latest commit SHA for a specific file path from a GitHub repository.
/// Uses the Atom feed to avoid API rate limiting.
/// </summary>
/// <param name="owner">Repository owner</param>
/// <param name="repo">Repository name</param>
/// <param name="branch">Branch name (e.g., "main")</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The commit SHA or null if failed</returns>
Task<string?> GetLatestCommitSha(string owner, string repo, string branch, CancellationToken cancellationToken);
/// <summary>
/// Gets the list of files in a directory from a GitHub repository.
/// Note: Uses GitHub API which has rate limits (60 requests/hour unauthenticated).
/// </summary>
/// <param name="owner">Repository owner</param>
/// <param name="repo">Repository name</param>
/// <param name="path">Directory path in the repository</param>
/// <param name="branch">Branch name (e.g., "main")</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of file names in the directory, or empty if failed</returns>
Task<IEnumerable<string>> GetDirectoryContents(string owner, string repo, string path, string branch, CancellationToken cancellationToken);
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
internal sealed class DirectSongRegistrar(
ILogger<DirectSongRegistrar> 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<DirectSongRegistrar> logger = logger;
public async Task<bool> RegisterDirectSongDirectory(string installationDirectory, string registryEditorName, CancellationToken cancellationToken)
public Task<bool> 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<bool> 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<bool> SetupPlatformFiles(string installationDirectory, IDownloadService downloadService, IProgress<ProgressUpdate>? 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;
}
}
+1
View File
@@ -12,6 +12,7 @@
<PackageVersion Include="ini-parser-netstandard" Version="2.5.3" />
<PackageVersion Include="ksemenenko.ColorThief" Version="1.1.1.4" />
<PackageVersion Include="MeaMod.DNS" Version="1.0.71" />
<PackageVersion Include="MegaApiClient" Version="1.10.5" />
<PackageVersion Include="MemoryPack" Version="1.21.4" />
<PackageVersion Include="MemoryPack.Generator" Version="1.21.4" />
<PackageVersion Include="Microsoft.AspNetCore.Components" Version="10.0.1" />