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" "*"