diff --git a/Configuration/Config.cs b/Configuration/Config.cs index 77b445b..8da8260 100644 --- a/Configuration/Config.cs +++ b/Configuration/Config.cs @@ -4,6 +4,8 @@ namespace Quaver.Steam.Deploy.Configuration { public class Config { + private static string ConfigPath { get; set; } + /// /// Steam Username /// @@ -37,7 +39,7 @@ public class Config /// /// Quaver API JWT /// - public string QuaverAPIJWT { get; set; } = ""; + public string QuaverApijwt { get; set; } = ""; /// /// Whether or not the script will deploy the builds to Steam @@ -48,33 +50,38 @@ public class Config /// Run .NET Reactor /// public bool RunReactor { get; set; } + + /// + /// Optional path to a macOS app icon file (.icns, .png, or .ico). + /// + public string MacAppIconPath { get; set; } = ""; /// /// The path of the config file. /// - public static string Path => $"{Directory.GetCurrentDirectory()}/config.json"; + public static string Path => ConfigPath ?? System.IO.Path.Combine(Directory.GetCurrentDirectory(), "config.json"); /// /// Deserializes the config into an object. /// /// - public static Config Deserialize() + public static Config Deserialize(string path = null) { - const string path = "./config.json"; + ConfigPath = path ?? Path; // If the file doesn't exist, then we'll want to create the file, then throw a FileNotFoundException - if (!File.Exists(path)) + if (!File.Exists(ConfigPath)) { var config = new Config(); config.Save(); - throw new FileNotFoundException("config.json file was not found. A template has been created for you."); + throw new FileNotFoundException($"config.json file was not found at {ConfigPath}. A template has been created for you."); } Config parsedConfig; // Deserialize it if it already exists. - using (var fileStream = File.OpenRead(path)) + using (var fileStream = File.OpenRead(ConfigPath)) { parsedConfig = JsonSerializer.Deserialize(fileStream); } @@ -101,4 +108,4 @@ private void Save() } } } -} \ No newline at end of file +} diff --git a/Images/Quaver.icns b/Images/Quaver.icns new file mode 100644 index 0000000..a3ece55 Binary files /dev/null and b/Images/Quaver.icns differ diff --git a/Images/Quaver.iconset/icon_128x128.png b/Images/Quaver.iconset/icon_128x128.png new file mode 100644 index 0000000..425d02d Binary files /dev/null and b/Images/Quaver.iconset/icon_128x128.png differ diff --git a/Images/Quaver.iconset/icon_128x128@2x.png b/Images/Quaver.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..104807f Binary files /dev/null and b/Images/Quaver.iconset/icon_128x128@2x.png differ diff --git a/Images/Quaver.iconset/icon_16x16.png b/Images/Quaver.iconset/icon_16x16.png new file mode 100644 index 0000000..7dc7468 Binary files /dev/null and b/Images/Quaver.iconset/icon_16x16.png differ diff --git a/Images/Quaver.iconset/icon_16x16@2x.png b/Images/Quaver.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..3df7791 Binary files /dev/null and b/Images/Quaver.iconset/icon_16x16@2x.png differ diff --git a/Images/Quaver.iconset/icon_256x256.png b/Images/Quaver.iconset/icon_256x256.png new file mode 100644 index 0000000..104807f Binary files /dev/null and b/Images/Quaver.iconset/icon_256x256.png differ diff --git a/Images/Quaver.iconset/icon_256x256@2x.png b/Images/Quaver.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..35d839b Binary files /dev/null and b/Images/Quaver.iconset/icon_256x256@2x.png differ diff --git a/Images/Quaver.iconset/icon_32x32.png b/Images/Quaver.iconset/icon_32x32.png new file mode 100644 index 0000000..3df7791 Binary files /dev/null and b/Images/Quaver.iconset/icon_32x32.png differ diff --git a/Images/Quaver.iconset/icon_32x32@2x.png b/Images/Quaver.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..8c96906 Binary files /dev/null and b/Images/Quaver.iconset/icon_32x32@2x.png differ diff --git a/Images/Quaver.iconset/icon_512x512.png b/Images/Quaver.iconset/icon_512x512.png new file mode 100644 index 0000000..35d839b Binary files /dev/null and b/Images/Quaver.iconset/icon_512x512.png differ diff --git a/Images/Quaver.iconset/icon_512x512@2x.png b/Images/Quaver.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..955c4b1 Binary files /dev/null and b/Images/Quaver.iconset/icon_512x512@2x.png differ diff --git a/MacAppPackager.cs b/MacAppPackager.cs new file mode 100644 index 0000000..4845045 --- /dev/null +++ b/MacAppPackager.cs @@ -0,0 +1,631 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Xml.Linq; +using Quaver.Steam.Deploy.Configuration; + +namespace Quaver.Steam.Deploy; + +internal static class MacAppPackager +{ + private const string AppName = "Quaver.app"; + + private const string BundleIdentifier = "com.quavergame.Quaver"; + + internal static void Package(string currentDirectory, string compiledBuildPath, string sourceCodePath, string version, Config configuration) + { + Console.WriteLine("Creating universal macOS build..."); + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + throw new PlatformNotSupportedException("Universal macOS packaging requires lipo and must be run on macOS."); + + var macAppBuildPath = Path.Combine(compiledBuildPath, "content-osx"); + DeleteAndCreate(macAppBuildPath); + + var x64BuildPath = Path.Combine(compiledBuildPath, "content-osx-x64"); + var arm64BuildPath = Path.Combine(compiledBuildPath, "content-osx-arm64"); + + CopyDirectory(arm64BuildPath, macAppBuildPath); + CreateUniversalMachOBinaries(x64BuildPath, arm64BuildPath, macAppBuildPath, currentDirectory); + + var executablePath = Path.Combine(macAppBuildPath, "Quaver"); + + var appPath = Path.Combine(macAppBuildPath, AppName); + var contentsPath = Path.Combine(appPath, "Contents"); + var macOsPath = Path.Combine(contentsPath, "MacOS"); + var resourcesPath = Path.Combine(contentsPath, "Resources"); + + Directory.CreateDirectory(macOsPath); + Directory.CreateDirectory(resourcesPath); + + var iconFileName = CopyAppIcon(resourcesPath, currentDirectory, sourceCodePath, configuration); + var documentIconFileName = CopyDocumentIcon(resourcesPath, currentDirectory, sourceCodePath, configuration, iconFileName); + ReplaceRuntimeDockIcons(macAppBuildPath, currentDirectory); + + var launcherPath = Path.Combine(macOsPath, "Quaver"); + CreateAppLauncher(launcherPath, macOsPath, currentDirectory); + RunCommand("chmod", new[] { "+x", launcherPath }, currentDirectory); + RunCommand("chmod", new[] { "+x", executablePath }, currentDirectory); + + File.WriteAllText(Path.Combine(contentsPath, "Info.plist"), CreateInfoPlist(version, iconFileName, documentIconFileName)); + + DeleteDirectoryIfExists(x64BuildPath); + DeleteDirectoryIfExists(arm64BuildPath); + + Console.WriteLine($"Created universal macOS build at {macAppBuildPath}"); + } + + private static void DeleteDirectoryIfExists(string path) + { + if (Directory.Exists(path)) + Directory.Delete(path, true); + } + + private static void CreateAppLauncher(string launcherPath, string buildDirectory, string currentDirectory) + { + var sourcePath = Path.Combine(buildDirectory, "QuaverLauncher.m"); + File.WriteAllText(sourcePath, CreateAppLauncherSource()); + + RunCommand("xcrun", new[] + { + "clang", + "-fobjc-arc", + "-framework", + "Cocoa", + "-mmacosx-version-min=10.15", + "-arch", + "x86_64", + "-arch", + "arm64", + sourcePath, + "-o", + launcherPath + }, currentDirectory); + + File.Delete(sourcePath); + } + + private static string CreateAppLauncherSource() + { + return """ + #import + + @interface QuaverAppDelegate : NSObject + @property(nonatomic) BOOL launchedGame; + @end + + @implementation QuaverAppDelegate + + - (NSString *)installDirectory { + NSURL *bundleURL = [[NSBundle mainBundle] bundleURL]; + return [[[bundleURL URLByDeletingLastPathComponent] path] stringByStandardizingPath]; + } + + - (void)launchQuaverWithArguments:(NSArray *)arguments { + NSString *installDirectory = [self installDirectory]; + NSString *launcherPath = [installDirectory stringByAppendingPathComponent:@"Quaver"]; + + NSTask *task = [[NSTask alloc] init]; + task.executableURL = [NSURL fileURLWithPath:launcherPath]; + task.currentDirectoryURL = [NSURL fileURLWithPath:installDirectory isDirectory:YES]; + task.arguments = arguments ?: @[]; + + NSMutableDictionary *environment = [[[NSProcessInfo processInfo] environment] mutableCopy]; + environment[@"QUAVER_INSTALL_DIR"] = installDirectory; + environment[@"SDL_APP_NAME"] = @"Quaver"; + task.environment = environment; + + NSError *error = nil; + if (![task launchAndReturnError:&error]) { + NSLog(@"Failed to launch Quaver: %@", error); + } + + self.launchedGame = YES; + } + + - (void)applicationDidFinishLaunching:(NSNotification *)notification { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + if (!self.launchedGame) { + [self launchQuaverWithArguments:@[]]; + } + + [NSApp terminate:nil]; + }); + } + + - (void)application:(NSApplication *)application openURLs:(NSArray *)urls { + NSMutableArray *arguments = [NSMutableArray arrayWithCapacity:urls.count]; + + for (NSURL *url in urls) { + [arguments addObject:url.isFileURL ? url.path : url.absoluteString]; + } + + [self launchQuaverWithArguments:arguments]; + [NSApp terminate:nil]; + } + + - (BOOL)application:(NSApplication *)sender openFile:(NSString *)filename { + [self launchQuaverWithArguments:@[filename]]; + [NSApp terminate:nil]; + return YES; + } + + @end + + int main(int argc, const char * argv[]) { + @autoreleasepool { + NSApplication *application = [NSApplication sharedApplication]; + QuaverAppDelegate *delegate = [[QuaverAppDelegate alloc] init]; + application.delegate = delegate; + [application run]; + } + + return 0; + } + """; + } + + private static string CreateInfoPlist(string version, string iconFileName, string documentIconFileName) + { + var plistAppIconName = GetPlistIconName(iconFileName); + var plistDocumentIconName = GetPlistIconName(documentIconFileName); + + var plist = new XDocument( + new XDeclaration("1.0", "UTF-8", null), + new XDocumentType("plist", "-//Apple//DTD PLIST 1.0//EN", "http://www.apple.com/DTDs/PropertyList-1.0.dtd", null), + new XElement("plist", + new XAttribute("version", "1.0"), + new XElement("dict", + PlistKeyValue("CFBundleDevelopmentRegion", "en"), + PlistKeyValue("CFBundleDisplayName", "Quaver"), + PlistKeyValue("CFBundleExecutable", "Quaver"), + PlistKeyValue("CFBundleIdentifier", BundleIdentifier), + PlistKeyValue("CFBundleName", "Quaver"), + PlistKeyValue("CFBundlePackageType", "APPL"), + PlistKeyValue("CFBundleShortVersionString", version), + PlistKeyValue("CFBundleVersion", version), + PlistKeyValue("LSMinimumSystemVersion", "10.15"), + PlistKeyValue("NSHighResolutionCapable", true), + CreateUrlTypes(), + CreateDocumentTypes(plistDocumentIconName), + CreateExportedTypeDeclarations(plistDocumentIconName) + ) + ) + ); + + if (!string.IsNullOrEmpty(plistAppIconName)) + plist.Root?.Element("dict")?.AddFirst(PlistKeyValue("CFBundleIconFile", plistAppIconName)); + + return plist.ToString(); + } + + private static string GetPlistIconName(string iconFileName) + { + if (string.IsNullOrWhiteSpace(iconFileName)) + return ""; + + return Path.GetFileNameWithoutExtension(iconFileName); + } + + private static object[] PlistKeyValue(string key, string value) + { + return new object[] { new XElement("key", key), new XElement("string", value) }; + } + + private static object[] PlistKeyValue(string key, bool value) + { + return new object[] { new XElement("key", key), new XElement(value ? "true" : "false") }; + } + + private static object[] CreateDocumentTypes(string iconFileName) + { + return new object[] + { + new XElement("key", "CFBundleDocumentTypes"), + new XElement("array", + CreateDocumentType("Quaver Package", "qp", "com.quavergame.package", iconFileName), + CreateDocumentType("Quaver Skin", "qs", "com.quavergame.skin", iconFileName), + CreateDocumentType("Quaver Playlist", "qpl", "com.quavergame.playlist", iconFileName)) + }; + } + + private static object[] CreateExportedTypeDeclarations(string iconFileName) + { + return new object[] + { + new XElement("key", "UTExportedTypeDeclarations"), + new XElement("array", + CreateExportedTypeDeclaration("Quaver Package", "qp", "com.quavergame.package", iconFileName), + CreateExportedTypeDeclaration("Quaver Skin", "qs", "com.quavergame.skin", iconFileName), + CreateExportedTypeDeclaration("Quaver Playlist", "qpl", "com.quavergame.playlist", iconFileName)) + }; + } + + private static object[] CreateUrlTypes() + { + return new object[] + { + new XElement("key", "CFBundleURLTypes"), + new XElement("array", + new XElement("dict", + PlistKeyValue("CFBundleURLName", "Quaver URL"), + PlistKeyValue("CFBundleURLRole", "Viewer"), + new XElement("key", "CFBundleURLSchemes"), + new XElement("array", new XElement("string", "quaver")))) + }; + } + + private static XElement CreateDocumentType(string name, string extension, string uti, string iconFileName) + { + var documentType = new XElement("dict", + PlistKeyValue("CFBundleTypeName", name), + new XElement("key", "CFBundleTypeExtensions"), + new XElement("array", new XElement("string", extension)), + PlistKeyValue("CFBundleTypeRole", "Viewer"), + PlistKeyValue("LSHandlerRank", "Owner"), + new XElement("key", "LSItemContentTypes"), + new XElement("array", new XElement("string", uti))); + + if (!string.IsNullOrEmpty(iconFileName)) + documentType.Add(PlistKeyValue("CFBundleTypeIconFile", iconFileName)); + + return documentType; + } + + private static XElement CreateExportedTypeDeclaration(string description, string extension, string uti, string iconFileName) + { + var typeDeclaration = new XElement("dict", + PlistKeyValue("UTTypeIdentifier", uti), + PlistKeyValue("UTTypeDescription", description), + new XElement("key", "UTTypeConformsTo"), + new XElement("array", new XElement("string", "public.data")), + new XElement("key", "UTTypeTagSpecification"), + new XElement("dict", + new XElement("key", "public.filename-extension"), + new XElement("array", new XElement("string", extension)))); + + if (!string.IsNullOrEmpty(iconFileName)) + typeDeclaration.Add(PlistKeyValue("UTTypeIconFile", iconFileName)); + + return typeDeclaration; + } + + private static string CopyAppIcon(string resourcesPath, string currentDirectory, string sourceCodePath, Config configuration) + { + var iconPath = ResolveAppIconPath(currentDirectory, sourceCodePath, configuration); + + if (string.IsNullOrEmpty(iconPath)) + { + Console.WriteLine("No macOS app icon was found. Set MacAppIconPath in config.json to include one."); + return ""; + } + + var extension = Path.GetExtension(iconPath).ToLowerInvariant(); + var iconFileName = extension == ".icns" ? "Quaver.icns" : $"QuaverIcon{extension}"; + File.Copy(iconPath, Path.Combine(resourcesPath, iconFileName), true); + + return iconFileName; + } + + private static string CopyDocumentIcon(string resourcesPath, string currentDirectory, string sourceCodePath, Config configuration, string appIconFileName) + { + var iconPath = ResolveAppIconPath(currentDirectory, sourceCodePath, configuration); + + if (string.IsNullOrEmpty(iconPath)) + return ""; + + var extension = Path.GetExtension(iconPath).ToLowerInvariant(); + var iconFileName = extension == ".icns" ? "QuaverDocument.icns" : $"QuaverDocument{extension}"; + + if (iconFileName.Equals(appIconFileName, StringComparison.OrdinalIgnoreCase)) + iconFileName = $"Document{iconFileName}"; + + File.Copy(iconPath, Path.Combine(resourcesPath, iconFileName), true); + + return iconFileName; + } + + private static string ResolveAppIconPath(string currentDirectory, string sourceCodePath, Config configuration) + { + if (!string.IsNullOrWhiteSpace(configuration.MacAppIconPath)) + { + var configuredPath = ResolveRelativePath(currentDirectory, configuration.MacAppIconPath); + + if (File.Exists(configuredPath)) + return configuredPath; + + Console.WriteLine($"Configured macOS app icon was not found: {configuredPath}"); + } + + var searchRoots = new[] + { + Path.Combine(currentDirectory, "Images"), + sourceCodePath, + Path.Combine(sourceCodePath, "Quaver"), + Path.Combine(sourceCodePath, "Quaver", "Assets"), + Path.Combine(sourceCodePath, "Quaver", "Resources") + }; + + var extensions = new[] { ".icns", ".png", ".ico" }; + + foreach (var root in searchRoots.Where(Directory.Exists)) + { + var paddedIcon = Path.Combine(root, "Quaver.padded.icns"); + + if (File.Exists(paddedIcon)) + return paddedIcon; + + var icon = Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .Where(path => extensions.Contains(Path.GetExtension(path).ToLowerInvariant())) + .OrderByDescending(path => Path.GetExtension(path).Equals(".icns", StringComparison.OrdinalIgnoreCase)) + .ThenBy(path => Path.GetFileName(path).Contains("icon", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .FirstOrDefault(); + + if (icon != null) + return icon; + } + + return ""; + } + + private static string ResolveRelativePath(string currentDirectory, string path) + { + if (Path.IsPathRooted(path)) + return path; + + var outputRelativePath = Path.Combine(currentDirectory, path); + + if (File.Exists(outputRelativePath)) + return outputRelativePath; + + return Path.Combine(Directory.GetCurrentDirectory(), path); + } + + private static void ReplaceRuntimeDockIcons(string macAppBuildPath, string currentDirectory) + { + var iconImagePath = ResolveRuntimeIconImagePath(currentDirectory); + + if (string.IsNullOrEmpty(iconImagePath)) + { + Console.WriteLine("No runtime icon image was found in Images. Skipping icon.bmp and Icon.ico replacement."); + return; + } + + ConvertAndReplaceRuntimeIcon(macAppBuildPath, currentDirectory, iconImagePath, "icon.bmp", "bmp"); + CopyRuntimeIconIfAvailable(macAppBuildPath, currentDirectory, "Icon.ico"); + } + + private static string ResolveRuntimeIconImagePath(string currentDirectory) + { + var imagesPath = Path.Combine(currentDirectory, "Images"); + + if (!Directory.Exists(imagesPath)) + return ""; + + var preferredFiles = new[] + { + Path.Combine(imagesPath, "dock-icon.png"), + Path.Combine(imagesPath, "Quaver.png") + }; + + foreach (var preferredFile in preferredFiles.Where(File.Exists)) + return preferredFile; + + var iconsetPath = Path.Combine(imagesPath, "Quaver.padded.iconset"); + + if (!Directory.Exists(iconsetPath)) + iconsetPath = Path.Combine(imagesPath, "Quaver.iconset"); + + if (Directory.Exists(iconsetPath)) + { + var iconsetPng = Directory.EnumerateFiles(iconsetPath, "*.png", SearchOption.TopDirectoryOnly) + .OrderByDescending(GetIconsetImageSize) + .FirstOrDefault(); + + if (iconsetPng != null) + return iconsetPng; + } + + return Directory.EnumerateFiles(imagesPath, "*.png", SearchOption.TopDirectoryOnly) + .OrderBy(path => Path.GetFileName(path).Contains("icon", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .FirstOrDefault() ?? ""; + } + + private static int GetIconsetImageSize(string path) + { + var fileName = Path.GetFileNameWithoutExtension(path); + var sizePart = fileName.Split('_').FirstOrDefault(part => part.Contains('x', StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrEmpty(sizePart)) + return 0; + + var dimensions = sizePart.Split('x'); + + if (dimensions.Length == 0 || !int.TryParse(dimensions[0], out var size)) + return 0; + + return fileName.Contains("@2x", StringComparison.OrdinalIgnoreCase) ? size * 2 : size; + } + + private static void ConvertAndReplaceRuntimeIcon(string macAppBuildPath, string currentDirectory, string iconImagePath, string targetFileName, string format) + { + var targetPaths = Directory.EnumerateFiles(macAppBuildPath, targetFileName, SearchOption.AllDirectories) + .Append(Path.Combine(macAppBuildPath, targetFileName)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + foreach (var targetPath in targetPaths) + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? macAppBuildPath); + RunCommand("sips", new[] { "-s", "format", format, iconImagePath, "--out", targetPath }, currentDirectory); + Console.WriteLine($"Replaced macOS runtime icon: {Path.GetRelativePath(macAppBuildPath, targetPath)}"); + } + } + + private static void CopyRuntimeIconIfAvailable(string macAppBuildPath, string currentDirectory, string targetFileName) + { + var iconPath = Path.Combine(currentDirectory, "Images", targetFileName); + + if (!File.Exists(iconPath)) + return; + + var targetPaths = Directory.EnumerateFiles(macAppBuildPath, targetFileName, SearchOption.AllDirectories) + .Append(Path.Combine(macAppBuildPath, targetFileName)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + foreach (var targetPath in targetPaths) + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? macAppBuildPath); + File.Copy(iconPath, targetPath, true); + Console.WriteLine($"Replaced macOS runtime icon: {Path.GetRelativePath(macAppBuildPath, targetPath)}"); + } + } + + private static void CopyDirectory(string sourceDirectory, string destinationDirectory) + { + if (!Directory.Exists(sourceDirectory)) + throw new DirectoryNotFoundException($"Could not find directory to copy: {sourceDirectory}"); + + Directory.CreateDirectory(destinationDirectory); + + foreach (var directory in Directory.GetDirectories(sourceDirectory, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceDirectory, directory); + Directory.CreateDirectory(Path.Combine(destinationDirectory, relativePath)); + } + + foreach (var file in Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceDirectory, file); + File.Copy(file, Path.Combine(destinationDirectory, relativePath), true); + } + } + + private static void CreateUniversalMachOBinaries(string x64BuildPath, string arm64BuildPath, string outputBuildPath, string currentDirectory) + { + foreach (var x64File in Directory.GetFiles(x64BuildPath, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(x64BuildPath, x64File); + var arm64File = Path.Combine(arm64BuildPath, relativePath); + var outputFile = Path.Combine(outputBuildPath, relativePath); + + if (!File.Exists(arm64File) || !IsMachO(x64File, currentDirectory) || !IsMachO(arm64File, currentDirectory)) + continue; + + var x64Architectures = GetArchitectures(x64File, currentDirectory); + var arm64Architectures = GetArchitectures(arm64File, currentDirectory); + + if (x64Architectures.SequenceEqual(arm64Architectures)) + { + Console.WriteLine($"Skipping universal merge for {relativePath}; both files contain {string.Join(" ", x64Architectures)}."); + WarnIfNotUniversal(relativePath, x64Architectures); + continue; + } + + if (x64Architectures.Intersect(arm64Architectures).Any()) + { + if (relativePath.Equals("Quaver", StringComparison.Ordinal)) + throw new InvalidOperationException($"Cannot create universal Quaver executable because both publishes contain overlapping architectures. x64: {string.Join(" ", x64Architectures)}, arm64: {string.Join(" ", arm64Architectures)}"); + + Console.WriteLine($"Skipping universal merge for {relativePath}; architectures overlap. x64: {string.Join(" ", x64Architectures)}, arm64: {string.Join(" ", arm64Architectures)}."); + WarnIfNotUniversal(relativePath, x64Architectures.Union(arm64Architectures).OrderBy(architecture => architecture, StringComparer.Ordinal).ToArray()); + continue; + } + + Console.WriteLine($"Creating universal binary: {relativePath}"); + RunCommand("lipo", new[] { "-create", x64File, arm64File, "-output", outputFile }, currentDirectory); + } + } + + private static bool IsMachO(string path, string currentDirectory) + { + var output = RunCommandWithOutput("file", new[] { path }, currentDirectory); + return output.Contains("Mach-O", StringComparison.Ordinal); + } + + private static void WarnIfNotUniversal(string relativePath, string[] architectures) + { + if (!architectures.Contains("arm64") || !architectures.Contains("x86_64")) + Console.WriteLine($"Warning: {relativePath} is not universal after packaging. Architectures: {string.Join(" ", architectures)}."); + } + + private static string[] GetArchitectures(string path, string currentDirectory) + { + var output = RunCommandWithOutput("lipo", new[] { "-archs", path }, currentDirectory); + return output + .Split((char[])null, StringSplitOptions.RemoveEmptyEntries) + .OrderBy(architecture => architecture, StringComparer.Ordinal) + .ToArray(); + } + + private static void DeleteAndCreate(string path) + { + if (Directory.Exists(path)) + Directory.Delete(path, true); + + Directory.CreateDirectory(path); + } + + private static void RunCommand(string command, string[] args, string workingDirectory) + { + var processStartInfo = new ProcessStartInfo(command) + { + WorkingDirectory = workingDirectory, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WindowStyle = ProcessWindowStyle.Hidden + }; + + foreach (var arg in args) + processStartInfo.ArgumentList.Add(arg); + + using var process = Process.Start(processStartInfo); + + if (process == null) + throw new InvalidOperationException($"Failed to start command: {command}"); + + var output = process.StandardOutput.ReadToEnd(); + output += process.StandardError.ReadToEnd(); + + process.WaitForExit(); + + if (process.ExitCode != 0) + throw new InvalidOperationException($"{command} failed with exit code {process.ExitCode}: {output}"); + } + + private static string RunCommandWithOutput(string command, string[] args, string workingDirectory) + { + var processStartInfo = new ProcessStartInfo(command) + { + WorkingDirectory = workingDirectory, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WindowStyle = ProcessWindowStyle.Hidden + }; + + foreach (var arg in args) + processStartInfo.ArgumentList.Add(arg); + + using var process = Process.Start(processStartInfo); + + if (process == null) + throw new InvalidOperationException($"Failed to start command: {command}"); + + var output = process.StandardOutput.ReadToEnd(); + output += process.StandardError.ReadToEnd(); + + process.WaitForExit(); + + if (process.ExitCode != 0) + throw new InvalidOperationException($"{command} failed with exit code {process.ExitCode}: {output}"); + + return output; + } +} diff --git a/Program.cs b/Program.cs index a58d13d..fd6a69d 100644 --- a/Program.cs +++ b/Program.cs @@ -3,9 +3,11 @@ using System.Diagnostics; using System.IO; using System.IO.Compression; +using System.Formats.Tar; using Quaver.Steam.Deploy.Configuration; using System.Linq; using System.Net.Http; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Xml.Linq; @@ -13,13 +15,17 @@ namespace Quaver.Steam.Deploy { internal static class Program { - private static readonly string CurrentDirectory = Directory.GetCurrentDirectory(); + private static readonly string CurrentDirectory = AppContext.BaseDirectory; - private static string CompiledBuildPath => CurrentDirectory + "\\build"; + private static string CompiledBuildPath => Path.Combine(CurrentDirectory, "build"); - private static string SourceCodePath => CurrentDirectory + "\\quaver"; + private static string SourceCodePath => Path.Combine(CurrentDirectory, "quaver"); - private static string SteamCMDPath => CurrentDirectory + "\\steamcmd"; + private static string ClientProjectPath => Path.Combine(SourceCodePath, "Quaver", "Quaver.csproj"); + + private static string SteamCmdPath => Path.Combine(CurrentDirectory, "steamcmd"); + + private const double IconPaddingScale = 0.85; private static string Version { get; set; } @@ -34,7 +40,14 @@ internal static class Program "win-x64", "linux-x64", "osx-x64", - "osx-arm", + "osx-arm64", + }; + + private static string[] DeployPlatforms { get; } = + { + "win-x64", + "linux-x64", + "osx" }; /// @@ -42,13 +55,15 @@ internal static class Program /// static void Main(string[] args) { - Configuration = Config.Deserialize(); + Directory.SetCurrentDirectory(CurrentDirectory); + Configuration = Config.Deserialize(Path.Combine(CurrentDirectory, "config.json")); SetupSteamCMD(); CleanUp(); GameVersion(); Branch(); CloneProject(); BuildProject(); + MacAppPackager.Package(CurrentDirectory, CompiledBuildPath, SourceCodePath, Version, Configuration); ObfuscateClient(); HashProject(); SubmitHashes(); @@ -61,20 +76,21 @@ static void Main(string[] args) private static void CleanUp() { - // Delete cloned project + // Delete source code DeleteAndCreate(SourceCodePath); // Delete builds DeleteAndCreate(CompiledBuildPath); // Delete app_build.vdf - if (Directory.Exists($"{CurrentDirectory}\\Scripts\\app_build.vdf")) - Directory.Delete($"{CurrentDirectory}\\Scripts\\app_build.vdf"); + var appBuildPath = Path.Combine(CurrentDirectory, "Scripts", "app_build.vdf"); + if (File.Exists(appBuildPath)) + File.Delete(appBuildPath); } private static void DeleteAndCreate(string path) { if (Directory.Exists(path)) { - // This resolves not allowing us to delete git folder + // This resolves not allowing us to delete git var directory = new DirectoryInfo(path) { Attributes = FileAttributes.Normal }; foreach (var info in directory.GetFileSystemInfos("*", SearchOption.AllDirectories)) @@ -118,22 +134,177 @@ private static void CloneProject() private static void BuildProject() { // Update project version - // Temporary fix until we ship Monogame dll instead submodule - UpdateProjectVersion($"{SourceCodePath}\\Quaver\\Quaver.csproj", Version); + // Temporary fix until we ship Monogame dll instead of submodule + UpdateProjectVersion(ClientProjectPath, Version); + PrepareMacAppIcons(); foreach (var platform in Platforms) { Console.WriteLine($"Starting compiling {platform}!"); - var dir = $"{CompiledBuildPath}\\content-{platform}"; + var dir = Path.Combine(CompiledBuildPath, $"content-{platform}"); - RunCommand("dotnet", - $"publish {SourceCodePath} -f {Configuration.NetFramework} -r {platform} -c {Configuration.NetConfiguration} -o {dir} --self-contained", - false); + var succeeded = RunCommand("dotnet", new[] + { + "publish", + ClientProjectPath, + "-f", + Configuration.NetFramework, + "-r", + platform, + "-c", + Configuration.NetConfiguration, + "-o", + dir, + "--self-contained" + }, true); + + if (!succeeded) + throw new InvalidOperationException($"Failed to compile {platform}. See the dotnet publish output above."); } Console.WriteLine("Successfully finished compiling for all platforms!"); } + private static void PrepareMacAppIcons() + { + var iconsetPath = Path.Combine(CurrentDirectory, "Images", "Quaver.iconset"); + + if (!Directory.Exists(iconsetPath)) + { + Console.WriteLine($"No iconset was found at {iconsetPath}. Skipping macOS app icon preparation."); + return; + } + + var paddedIconsetPath = Path.Combine(CurrentDirectory, "Images", "Quaver.padded.iconset"); + CreatePaddedIconset(iconsetPath, paddedIconsetPath); + CreatePaddedIcns(paddedIconsetPath, Path.Combine(CurrentDirectory, "Images", "Quaver.padded.icns")); + Console.WriteLine("Prepared padded macOS app icons."); + } + + private static void CreatePaddedIconset(string sourceIconsetPath, string paddedIconsetPath) + { + DeleteAndCreate(paddedIconsetPath); + + foreach (var sourcePng in Directory.EnumerateFiles(sourceIconsetPath, "*.png", SearchOption.TopDirectoryOnly)) + { + var targetPng = Path.Combine(paddedIconsetPath, Path.GetFileName(sourcePng)); + var size = GetIconsetImageSize(sourcePng); + + if (size <= 0) + continue; + + CreatePaddedPng(sourcePng, targetPng, size); + } + } + + private static void CreatePaddedPng(string sourcePng, string targetPng, int canvasSize) + { + var scriptPath = Path.Combine(Path.GetTempPath(), "quaver-pad-icon.swift"); + File.WriteAllText(scriptPath, """ + import AppKit + + let sourcePath = CommandLine.arguments[1] + let targetPath = CommandLine.arguments[2] + let canvasSize = Double(CommandLine.arguments[3])! + let scale = Double(CommandLine.arguments[4])! + + guard let image = NSImage(contentsOfFile: sourcePath) else { + fatalError("Could not load source image") + } + + let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(canvasSize), + pixelsHigh: Int(canvasSize), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0)! + + bitmap.size = NSSize(width: canvasSize, height: canvasSize) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap) + NSColor.clear.set() + NSRect(x: 0, y: 0, width: canvasSize, height: canvasSize).fill() + + let imageSize = canvasSize * scale + let origin = (canvasSize - imageSize) / 2.0 + image.draw(in: NSRect(x: origin, y: origin, width: imageSize, height: imageSize), + from: NSRect(x: 0, y: 0, width: image.size.width, height: image.size.height), + operation: .sourceOver, + fraction: 1.0) + NSGraphicsContext.restoreGraphicsState() + + let data = bitmap.representation(using: .png, properties: [:])! + try data.write(to: URL(fileURLWithPath: targetPath)) + """); + + var succeeded = RunCommand("xcrun", new[] { "swift", scriptPath, sourcePng, targetPng, canvasSize.ToString(), IconPaddingScale.ToString(System.Globalization.CultureInfo.InvariantCulture) }); + + if (!succeeded) + throw new InvalidOperationException($"Failed to create padded icon: {targetPng}"); + } + + private static void CreatePaddedIcns(string paddedIconsetPath, string paddedIcnsPath) + { + var succeeded = RunCommand("iconutil", new[] { "-c", "icns", paddedIconsetPath, "-o", paddedIcnsPath }); + + if (!succeeded) + throw new InvalidOperationException($"Failed to create padded macOS app icon: {paddedIcnsPath}"); + } + + private static int GetIconsetImageSize(string path) + { + var fileName = Path.GetFileNameWithoutExtension(path); + var sizePart = fileName.Split('_').FirstOrDefault(part => part.Contains('x', StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrEmpty(sizePart)) + return 0; + + var dimensions = sizePart.Split('x'); + + if (dimensions.Length == 0 || !int.TryParse(dimensions[0], out var size)) + return 0; + + return fileName.Contains("@2x", StringComparison.OrdinalIgnoreCase) ? size * 2 : size; + } + + private static void WriteIcoFromPngs(string[] pngPaths, string outputPath) + { + using var output = new BinaryWriter(File.Create(outputPath)); + output.Write((ushort)0); + output.Write((ushort)1); + output.Write((ushort)pngPaths.Length); + + var imageOffset = 6 + pngPaths.Length * 16; + var pngBytes = pngPaths + .Select(File.ReadAllBytes) + .ToArray(); + + for (var i = 0; i < pngPaths.Length; i++) + { + var size = GetIconsetImageSize(pngPaths[i]); + var icoSize = size >= 256 ? 0 : size; + + output.Write((byte)icoSize); + output.Write((byte)icoSize); + output.Write((byte)0); + output.Write((byte)0); + output.Write((ushort)1); + output.Write((ushort)32); + output.Write(pngBytes[i].Length); + output.Write(imageOffset); + + imageOffset += pngBytes[i].Length; + } + + foreach (var png in pngBytes) + output.Write(png); + } + private static void ObfuscateClient() { if (!Configuration.RunReactor) @@ -143,35 +314,59 @@ private static void ObfuscateClient() } Console.WriteLine("Starting obfuscating client"); - // Run .NET Reactor for win-x64 - var contentPath = $"{CompiledBuildPath}\\content-win-x64"; + var contentPath = Path.Combine(CompiledBuildPath, "content-osx"); + var quaverDll = Path.Combine(contentPath, "Quaver.dll"); + var quaverServerClientDll = Path.Combine(contentPath, "Quaver.Server.Client.dll"); var commandline = - $"-licensed -file {contentPath}\\Quaver.dll -files {contentPath}\\Quaver.Server.Client.dll -antitamp 1 -anti_debug 1 -hide_calls 1 -hide_calls_internals 1 -control_flow 1 -flow_level 9 -resourceencryption 1 -antistrong 1 -virtualization 1 -necrobit 1 -mapping_file 1"; + $"-licensed -file {quaverDll} -files {quaverServerClientDll} -antitamp 1 -anti_debug 1 -hide_calls 1 -control_flow 1 -flow_level 9 -mapping_file 1"; - RunCommand(Configuration.NetReactor, commandline); + if (!RunCommand(Configuration.NetReactor, commandline)) + throw new InvalidOperationException("Failed to obfuscate client for osx. See the .NET Reactor output above."); - var quaverServerClient = $"{contentPath}\\Quaver.Server.Client_Secure\\Quaver.Server.Client.dll"; + var protectedQuaverServerClientDll = + Path.Combine(contentPath, "Quaver.Server.Client_Secure", "Quaver.Server.Client.dll"); - foreach (var platform in Platforms) + foreach (var platform in DeployPlatforms) { - var path = $"{CompiledBuildPath}\\content-{platform}"; - File.Copy(quaverServerClient, $"{path}\\Quaver.Server.Client.dll", true); + var platformContentPath = Path.Combine(CompiledBuildPath, $"content-{platform}"); + File.Copy(protectedQuaverServerClientDll, Path.Combine(platformContentPath, "Quaver.Server.Client.dll"), true); + DeleteFileIfExists(Path.Combine(platformContentPath, "Quaver.Server.Client.pdb")); } + + DeleteReactorOutputFolders(contentPath); Console.WriteLine("Finished obfuscating"); } + private static void DeleteReactorOutputFolders(string contentPath) + { + DeleteDirectoryIfExists(Path.Combine(contentPath, "Quaver_Secure")); + DeleteDirectoryIfExists(Path.Combine(contentPath, "Quaver.Server.Client_Secure")); + } + + private static void DeleteDirectoryIfExists(string path) + { + if (Directory.Exists(path)) + Directory.Delete(path, true); + } + + private static void DeleteFileIfExists(string path) + { + if (File.Exists(path)) + File.Delete(path); + } + private static void HashProject() { - foreach (var platform in Platforms) + foreach (var platform in DeployPlatforms) { var gameBuild = new GameBuild { Name = Version, - QuaverSharedMd5 = GetHash($"{CompiledBuildPath}\\content-{platform}\\Quaver.Shared.dll"), - QuaverApiMd5 = GetHash($"{CompiledBuildPath}\\content-{platform}\\Quaver.API.dll"), - QuaverServerClientMd5 = GetHash($"{CompiledBuildPath}\\content-{platform}\\Quaver.Server.Client.dll") + QuaverSharedMd5 = GetHash(Path.Combine(CompiledBuildPath, $"content-{platform}", "Quaver.Shared.dll")), + QuaverApiMd5 = GetHash(Path.Combine(CompiledBuildPath, $"content-{platform}", "Quaver.API.dll")), + QuaverServerClientMd5 = GetHash(Path.Combine(CompiledBuildPath, $"content-{platform}", "Quaver.Server.Client.dll")) }; GameBuilds.Add(gameBuild); } @@ -189,10 +384,16 @@ private static void SubmitHashes() { Console.WriteLine("Submitting hashes"); + if(!Configuration.DeployToSteam) + { + Console.WriteLine("Deploying to Steam is disabled in the config file. Skipping..."); + return; + } + foreach (var gameBuild in GameBuilds) { Console.WriteLine(gameBuild); - gameBuild.SendBuild(Configuration.QuaverAPIJWT); + gameBuild.SendBuild(Configuration.QuaverApijwt); } } @@ -205,40 +406,56 @@ private static void Deploy() } // Create app_build.vdf - var appBuildTemplate = File.ReadAllText($"{CurrentDirectory}\\Scripts\\app_build.template.vdf"); + var scriptsPath = Path.Combine(CurrentDirectory, "Scripts"); + var appBuildPath = Path.Combine(scriptsPath, "app_build.vdf"); + var appBuildTemplate = File.ReadAllText(Path.Combine(scriptsPath, "app_build.template.vdf")); var appBuild = appBuildTemplate.Replace("{build_desc}", $"{Version}"); - File.Create($"{CurrentDirectory}\\Scripts\\app_build.vdf").Dispose(); - File.WriteAllText($"{CurrentDirectory}\\Scripts\\app_build.vdf", appBuild); + File.Create(appBuildPath).Dispose(); + File.WriteAllText(appBuildPath, appBuild); Console.Write("Enter Steam Two Factor Authentication Code: "); var code = Console.ReadLine(); - - // Delete the reactor folders - string contentPath = $"{CompiledBuildPath}\\content-win-x64"; - - if (Directory.Exists($"{contentPath}\\Quaver_Secure")) - { - Directory.Delete($"{contentPath}\\Quaver_Secure", true); - } - - if (Directory.Exists($"{contentPath}\\Quaver.Server.Client_Secure")) - { - Directory.Delete($"{contentPath}\\Quaver.Server.Client_Secure", true); - } - Console.WriteLine("Deploying to Steam..."); // Deploy to Steam - RunCommand(SteamCMDPath + "\\steamcmd.exe", $"+login {Configuration.SteamUsername} \"{Configuration.SteamPassword}\" {code} +run_app_build_http {CurrentDirectory}/Scripts/app_build.vdf +quit", true); + RunCommand(Path.Combine(SteamCmdPath, GetSteamCmdExecutableName()), new[] + { + "+login", + Configuration.SteamUsername, + Configuration.SteamPassword, + code, + "+run_app_build_http", + appBuildPath, + "+quit" + }, true); Console.WriteLine("Finished deploying!"); } + private static string GetSteamCmdExecutableName() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "steamcmd.exe" : "steamcmd.sh"; + } + + private static (string Url, string ArchiveName, bool IsZip) GetSteamCmdPackage() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return ("https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip", "steamcmd.zip", true); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return ("https://steamcdn-a.akamaihd.net/client/installer/steamcmd_osx.tar.gz", "steamcmd_osx.tar.gz", false); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return ("https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz", "steamcmd_linux.tar.gz", false); + + throw new PlatformNotSupportedException("SteamCMD is only supported on Windows, macOS, and Linux."); + } + private static bool RunCommand(string command, string args, bool showOutput = true) { var processStartInfo = new ProcessStartInfo(command, args) { - WorkingDirectory = Environment.CurrentDirectory, + WorkingDirectory = CurrentDirectory, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, @@ -246,6 +463,29 @@ private static bool RunCommand(string command, string args, bool showOutput = tr WindowStyle = ProcessWindowStyle.Hidden }; + return RunProcess(processStartInfo, showOutput); + } + + private static bool RunCommand(string command, IEnumerable args, bool showOutput = true) + { + var processStartInfo = new ProcessStartInfo(command) + { + WorkingDirectory = CurrentDirectory, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WindowStyle = ProcessWindowStyle.Hidden + }; + + foreach (var arg in args) + processStartInfo.ArgumentList.Add(arg); + + return RunProcess(processStartInfo, showOutput); + } + + private static bool RunProcess(ProcessStartInfo processStartInfo, bool showOutput) + { var process = Process.Start(processStartInfo); if (process == null) @@ -269,18 +509,132 @@ private static bool RunCommand(string command, string args, bool showOutput = tr private static void RunCommandInNewTerminal(string command) { - var processStartInfo = new ProcessStartInfo + ProcessStartInfo processStartInfo; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - FileName = "cmd.exe", - Arguments = $"/K {command}", - UseShellExecute = true, - CreateNoWindow = false - }; + processStartInfo = new ProcessStartInfo + { + FileName = "cmd.exe", + WorkingDirectory = CurrentDirectory, + UseShellExecute = true, + CreateNoWindow = false + }; + processStartInfo.ArgumentList.Add("/K"); + processStartInfo.ArgumentList.Add(command); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + processStartInfo = new ProcessStartInfo + { + FileName = "osascript", + WorkingDirectory = CurrentDirectory, + UseShellExecute = false, + CreateNoWindow = false + }; + processStartInfo.ArgumentList.Add("-e"); + processStartInfo.ArgumentList.Add($"tell application \"Terminal\" to do script \"{EscapeAppleScriptString(GetUnixTerminalCommand(command))}\""); + } + else + { + processStartInfo = CreateLinuxTerminalStartInfo(command); + } using var process = new Process(); process.StartInfo = processStartInfo; process.Start(); } + + private static ProcessStartInfo CreateLinuxTerminalStartInfo(string command) + { + string[] terminalCommands = + { + "x-terminal-emulator", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "xterm" + }; + + foreach (var terminalCommand in terminalCommands) + { + if (!CommandExists(terminalCommand)) + continue; + + var processStartInfo = new ProcessStartInfo + { + FileName = terminalCommand, + WorkingDirectory = CurrentDirectory, + UseShellExecute = false, + CreateNoWindow = false + }; + + var terminalCommandLine = $"{GetUnixTerminalCommand(command)}; exec bash"; + + switch (terminalCommand) + { + case "gnome-terminal": + processStartInfo.ArgumentList.Add("--"); + processStartInfo.ArgumentList.Add("bash"); + processStartInfo.ArgumentList.Add("-lc"); + processStartInfo.ArgumentList.Add(terminalCommandLine); + break; + case "konsole": + case "xfce4-terminal": + processStartInfo.ArgumentList.Add("-e"); + processStartInfo.ArgumentList.Add("bash"); + processStartInfo.ArgumentList.Add("-lc"); + processStartInfo.ArgumentList.Add(terminalCommandLine); + break; + case "xterm": + case "x-terminal-emulator": + processStartInfo.ArgumentList.Add("-e"); + processStartInfo.ArgumentList.Add("bash"); + processStartInfo.ArgumentList.Add("-lc"); + processStartInfo.ArgumentList.Add(terminalCommandLine); + break; + } + + return processStartInfo; + } + + throw new PlatformNotSupportedException("Could not find a supported terminal emulator. Install x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, or xterm."); + } + + private static bool CommandExists(string command) + { + var processStartInfo = new ProcessStartInfo + { + FileName = "which", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + processStartInfo.ArgumentList.Add(command); + + using var process = Process.Start(processStartInfo); + if (process == null) + return false; + + process.WaitForExit(); + return process.ExitCode == 0; + } + + private static string EscapeAppleScriptString(string value) + { + return value.Replace("\\", "\\\\").Replace("\"", "\\\""); + } + + private static string GetUnixTerminalCommand(string command) + { + return $"cd {QuoteUnixShellArgument(CurrentDirectory)}; {command}"; + } + + private static string QuoteUnixShellArgument(string value) + { + return $"'{value.Replace("'", "'\\''")}'"; + } private static void UpdateProjectVersion(string projectFilePath, string newVersion) { @@ -325,34 +679,60 @@ private static void UpdateProjectVersion(string projectFilePath, string newVersi private static void SetupSteamCMD() { - var steamCMDUrl = "https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip"; - var steamCMDName = "steamcmd.zip"; + var steamCmdPackage = GetSteamCmdPackage(); + var steamCmdArchivePath = Path.Combine(CurrentDirectory, steamCmdPackage.ArchiveName); + var steamCmdExecutable = Path.Combine(SteamCmdPath, GetSteamCmdExecutableName()); - if (!Directory.Exists(SteamCMDPath)) + if (!File.Exists(steamCmdExecutable)) { Console.WriteLine("Downloading SteamCMD..."); - DownloadFile(steamCMDUrl, steamCMDName); - ZipFile.ExtractToDirectory($"./{steamCMDName}", SteamCMDPath); - + DownloadFile(steamCmdPackage.Url, steamCmdArchivePath); + Directory.CreateDirectory(SteamCmdPath); + + if (steamCmdPackage.IsZip) + ZipFile.ExtractToDirectory(steamCmdArchivePath, SteamCmdPath, true); + else + ExtractTarGzToDirectory(steamCmdArchivePath, SteamCmdPath); + + EnsureSteamCmdIsExecutable(steamCmdExecutable); + Console.WriteLine("Installing SteamCMD..."); - RunCommand($"{SteamCMDPath}\\steamcmd.exe", $"+quit", false); + RunCommand(steamCmdExecutable, "+quit", false); } - if (File.Exists($"./{steamCMDName}")) + EnsureSteamCmdIsExecutable(steamCmdExecutable); + + if (File.Exists(steamCmdArchivePath)) { - File.Delete($"./{steamCMDName}"); + File.Delete(steamCmdArchivePath); } } + + private static void EnsureSteamCmdIsExecutable(string steamCMDExecutable) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + + var mode = File.GetUnixFileMode(steamCMDExecutable); + File.SetUnixFileMode(steamCMDExecutable, mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); + } + + private static void ExtractTarGzToDirectory(string archivePath, string destinationDirectory) + { + using var archiveStream = File.OpenRead(archivePath); + using var gzipStream = new GZipStream(archiveStream, CompressionMode.Decompress); + TarFile.ExtractToDirectory(gzipStream, destinationDirectory, true); + } - static void DownloadFile(string url, string fileName) + static void DownloadFile(string url, string filePath) { using HttpClient client = new HttpClient(); using HttpResponseMessage response = client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).Result; response.EnsureSuccessStatusCode(); using Stream stream = response.Content.ReadAsStream(); - using FileStream fileStream = new FileStream($"./{fileName}", FileMode.Create, FileAccess.Write, FileShare.None); + using FileStream fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None); stream.CopyTo(fileStream); } } -} \ No newline at end of file +} diff --git a/Quaver.Steam.Deploy.csproj b/Quaver.Steam.Deploy.csproj index e6abd3a..c1c6b1d 100644 --- a/Quaver.Steam.Deploy.csproj +++ b/Quaver.Steam.Deploy.csproj @@ -7,5 +7,8 @@ PreserveNewest + + PreserveNewest + diff --git a/Scripts/app_build_macos_private.vdf b/Scripts/app_build_macos_private.vdf new file mode 100644 index 0000000..97d7d79 --- /dev/null +++ b/Scripts/app_build_macos_private.vdf @@ -0,0 +1,13 @@ +"AppBuild" +{ + "AppID" "980610" + "Desc" "macOS app test" + "BuildOutput" "./build_output" + "ContentRoot" "./" + "Preview" "0" + "SetLive" "macos-private" + "Depots" + { + "980612" "./depot_build_980612.vdf" + } +} diff --git a/Scripts/depot_build_980612.vdf b/Scripts/depot_build_980612.vdf index 950140b..d9b7023 100644 --- a/Scripts/depot_build_980612.vdf +++ b/Scripts/depot_build_980612.vdf @@ -1,7 +1,7 @@ "DepotBuildConfig" { "DepotID" "980612" - "ContentRoot" "..\\build\\content-osx-x64" + "ContentRoot" "..\\build\\content-osx" "FileMapping" { "LocalPath" "*"