diff --git a/macos-launcher/src/ActionView.swift b/macos-launcher/src/ActionView.swift index 504aa6d73..f9233725a 100644 --- a/macos-launcher/src/ActionView.swift +++ b/macos-launcher/src/ActionView.swift @@ -7,7 +7,7 @@ private let logger = Logger(subsystem: "com.stfcmod.startrekpatch", category: "l struct ActionView: View, XSollaUpdaterDelegate { - @StateObject var gameUpdater = GameUpdaterViewModel() + private let gameUpdater = XsollaUpdater("Star Trek Fleet Command") @State private var gameVersion: Int = 0 @State private var gameUpdateAvailable: Bool = false @State private var updating: Bool = false @@ -103,8 +103,8 @@ struct ActionView: View, XSollaUpdaterDelegate { showErrorAlert = true } updating = false - gameVersion = gameUpdater.getInstalledGameVersion() - gameUpdateAvailable = await gameUpdater.checkForGameUpdate() + gameVersion = gameUpdater.installedVersion() + gameUpdateAvailable = await gameUpdater.checkForUpdateAvailable() } } } @@ -141,10 +141,9 @@ struct ActionView: View, XSollaUpdaterDelegate { .offset(x: 85, y: 20) .task { repeat { - gameVersion = gameUpdater.getInstalledGameVersion() + gameVersion = gameUpdater.installedVersion() gameInstalled = gameVersion > 0 - gameVersion = gameUpdater.getInstalledGameVersion() - gameUpdateAvailable = await gameUpdater.checkForGameUpdate() + gameUpdateAvailable = await gameUpdater.checkForUpdateAvailable() try? await Task.sleep(for: .seconds(60)) } while !Task.isCancelled diff --git a/macos-launcher/src/ContentView.swift b/macos-launcher/src/ContentView.swift index a18604ac4..1c1ffa283 100644 --- a/macos-launcher/src/ContentView.swift +++ b/macos-launcher/src/ContentView.swift @@ -8,9 +8,7 @@ import Foundation import SwiftUI -struct ContentView: View, XSollaUpdaterDelegate { - @State private var updateAction: String = "" - +struct ContentView: View { var body: some View { GeometryReader { geo in ZStack { @@ -158,16 +156,6 @@ struct ContentView: View, XSollaUpdaterDelegate { } } - private var buttons: some View { - Grid { - GridRow { - - } - } - .frame(width: 300, height: 150) - .offset(x: 85, y: 300) - } - private func commonLabel(prefix: String) -> some View { HStack { Spacer() @@ -184,9 +172,6 @@ struct ContentView: View, XSollaUpdaterDelegate { .map { _ in "\(Int.random(in: 0...9))" } .joined() } - - func updateProgress(progress: XsollaUpdateProgress) { - } } extension Color { diff --git a/macos-launcher/src/GitHubLib.swift b/macos-launcher/src/GitHubLib.swift deleted file mode 100644 index 76da6d4a0..000000000 --- a/macos-launcher/src/GitHubLib.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -struct GitHubUpdater { - func latestPatchVersion() async -> Int { - let url = URL(string: "https://api.github.com/repos/STFC-Mod/mod/releases/latest")! - do { - let (data, _) = try await URLSession.shared.data(from: url) - let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] - let tagName = json["tag_name"] as! String - let versionSegments = tagName.split(separator: ".") - return Int(versionSegments[1]) ?? 0 - } catch { - return 0 - } - } -} diff --git a/macos-launcher/src/ManagedURL.swift b/macos-launcher/src/ManagedURL.swift deleted file mode 100644 index cb919ec88..000000000 --- a/macos-launcher/src/ManagedURL.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -public protocol ManagedURL { - var contentURL: URL { get } - func keepAlive() -} - -extension ManagedURL { - public func keepAlive() {} -} - -extension URL: ManagedURL { - public var contentURL: URL { return self } -} diff --git a/macos-launcher/src/PatchEntitlements.swift b/macos-launcher/src/PatchEntitlements.swift index 44e4e9e3b..5e561b39f 100644 --- a/macos-launcher/src/PatchEntitlements.swift +++ b/macos-launcher/src/PatchEntitlements.swift @@ -9,7 +9,7 @@ private let logger = Logger(subsystem: "com.stfcmod.startrekpatch", category: "e /// Entitlements required to enable the loader for STFC. /// The game ships with these from Scopely's Developer ID signature, so normally /// no re-signing is needed — we just verify they're present. -let loaderEntitlements: [String: Any] = [ +let loaderEntitlements: [String: Bool] = [ "com.apple.security.cs.allow-dyld-environment-variables": true, "com.apple.security.cs.allow-unsigned-executable-memory": true, "com.apple.security.cs.disable-library-validation": true, @@ -48,10 +48,6 @@ func ensureGameHasLoaderEntitlements(signingIdentity: String = "-") throws { // typically ships with the entitlements we need from Scopely's signature. if checkEntitlements(appPath: gamePath, expectedEntitlements: loaderEntitlements) { logger.info("Game already has the required loader entitlements, no re-signing needed") - // Clear any pending re-application flag since entitlements are valid - if UserDefaults.standard.bool(forKey: "forceEntitlementReapplication") { - UserDefaults.standard.set(false, forKey: "forceEntitlementReapplication") - } return } @@ -93,7 +89,6 @@ func ensureGameHasLoaderEntitlements(signingIdentity: String = "-") throws { // We only sign the main executable, preserving nested framework/plugin signatures logger.info("Applying loader entitlements to the game binary...") let applySuccess = applyEntitlementsDirectly( - appBundlePath: gameAppPath, mainExecutablePath: gamePath, entitlements: loaderEntitlements, signingIdentity: signingIdentity @@ -108,7 +103,6 @@ func ensureGameHasLoaderEntitlements(signingIdentity: String = "-") throws { } logger.info("Successfully applied loader entitlements to the game") - UserDefaults.standard.set(false, forKey: "forceEntitlementReapplication") } /// Fetches the path to the Star Trek Fleet Command game executable @@ -159,7 +153,7 @@ func fetchGamePath() throws -> String? { /// - appPath: Path to the .app bundle or executable to check /// - expectedEntitlements: Dictionary of entitlement keys and their expected values /// - Returns: true if all expected entitlements match, false otherwise -func checkEntitlements(appPath: String, expectedEntitlements: [String: Any]) -> Bool { +func checkEntitlements(appPath: String, expectedEntitlements: [String: Bool]) -> Bool { // Create process to run codesign command let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") @@ -205,8 +199,7 @@ func checkEntitlements(appPath: String, expectedEntitlements: [String: Any]) -> return false } - // Compare values (handling different types) - if !areValuesEqual(actualValue, expectedValue) { + if (actualValue as? Bool) != expectedValue { let expected = String(describing: expectedValue) let actual = String(describing: actualValue) logger.debug("Entitlement '\(key)' mismatch: expected \(expected), got \(actual)") @@ -297,16 +290,14 @@ private func requestGameBundleAccessOnMainThread( /// Applies entitlements to an application using codesign directly (no elevation) /// - Parameters: -/// - appBundlePath: Path to the .app bundle to sign /// - mainExecutablePath: Path to the main executable within the bundle /// - entitlements: Dictionary of entitlement keys and values to apply /// - signingIdentity: Optional signing identity (defaults to "-" for ad-hoc signing) /// - Returns: true if signing succeeded, false otherwise /// - Note: Must be called while security-scoped resource access is active func applyEntitlementsDirectly( - appBundlePath: String, mainExecutablePath: String, - entitlements: [String: Any], + entitlements: [String: Bool], signingIdentity: String = "-" ) -> Bool { // Create temporary entitlements plist file @@ -332,7 +323,6 @@ func applyEntitlementsDirectly( // Execute codesign directly (no elevation needed - user owns these files) let success = executeCodesignDirectly( - appBundlePath: appBundlePath, mainExecutablePath: mainExecutablePath, entitlementsPath: tempEntitlementsPath, signingIdentity: signingIdentity @@ -356,14 +346,12 @@ func applyEntitlementsDirectly( /// Executes codesign directly without elevation /// - Parameters: -/// - appBundlePath: Path to the .app bundle to sign /// - mainExecutablePath: Path to the main executable within the bundle /// - entitlementsPath: Path to temporary entitlements plist /// - signingIdentity: Code signing identity /// - Returns: true if successful, false otherwise /// - Note: Must be called while security-scoped resource access is active func executeCodesignDirectly( - appBundlePath: String, mainExecutablePath: String, entitlementsPath: String, signingIdentity: String @@ -456,52 +444,6 @@ func executeCodesignDirectly( } } -/// Helper function to compare two values of potentially different types -/// - Parameters: -/// - lhs: First value to compare -/// - rhs: Second value to compare -/// - Returns: true if values are equal, false otherwise -private func areValuesEqual(_ lhs: Any, _ rhs: Any) -> Bool { - // Handle Bool comparison - if let lhsBool = lhs as? Bool, let rhsBool = rhs as? Bool { - return lhsBool == rhsBool - } - - // Handle String comparison - if let lhsString = lhs as? String, let rhsString = rhs as? String { - return lhsString == rhsString - } - - // Handle Number/Int comparison - if let lhsNum = lhs as? NSNumber, let rhsNum = rhs as? NSNumber { - return lhsNum == rhsNum - } - - // Handle Array comparison - if let lhsArray = lhs as? [Any], let rhsArray = rhs as? [Any] { - guard lhsArray.count == rhsArray.count else { return false } - for (index, lhsElement) in lhsArray.enumerated() where !areValuesEqual(lhsElement, rhsArray[index]) { - return false - } - return true - } - - // Handle Dictionary comparison - if let lhsDict = lhs as? [String: Any], let rhsDict = rhs as? [String: Any] { - guard lhsDict.keys == rhsDict.keys else { return false } - for (key, lhsValue) in lhsDict { - guard let rhsValue = rhsDict[key] else { return false } - if !areValuesEqual(lhsValue, rhsValue) { - return false - } - } - return true - } - - // Fallback: try string comparison - return "\(lhs)" == "\(rhs)" -} - /// Checks if the game is currently running /// - Parameter bundlePath: Path to the .app bundle /// - Returns: true if the game is running, false otherwise diff --git a/macos-launcher/src/TemporaryFolderURL.swift b/macos-launcher/src/TemporaryFolderURL.swift index 2fe3c838b..f7b143868 100644 --- a/macos-launcher/src/TemporaryFolderURL.swift +++ b/macos-launcher/src/TemporaryFolderURL.swift @@ -1,6 +1,6 @@ import Foundation -public final class TemporaryFolderURL: ManagedURL { +public final class TemporaryFolderURL { public let contentURL: URL public init() { contentURL = URL(fileURLWithPath: NSTemporaryDirectory()) diff --git a/macos-launcher/src/ViewModel/GameUpdaterViewModel.swift b/macos-launcher/src/ViewModel/GameUpdaterViewModel.swift deleted file mode 100644 index 7a1fe8598..000000000 --- a/macos-launcher/src/ViewModel/GameUpdaterViewModel.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -class GameUpdaterViewModel: ObservableObject { - @Published var latestGameVersion = 0 - - var gameUpdater: XsollaUpdater = XsollaUpdater("Star Trek Fleet Command") - - @MainActor - func fetchLatestGameVersion() async -> Int { - latestGameVersion = try! await gameUpdater.latestGameVersion() - return latestGameVersion - } - - @MainActor - func getInstalledGameVersion() -> Int { - return gameUpdater.installedVersion() - } - - @MainActor - func checkForGameUpdate() async -> Bool { - return await gameUpdater.checkForUpdateAvailable() - } - - @MainActor - func updateGame(delegate: XSollaUpdaterDelegate? = nil) async throws { - try await gameUpdater.updateGame(delegate: delegate) - } -} diff --git a/macos-launcher/src/XsollaLib.swift b/macos-launcher/src/XsollaLib.swift index d1bc41c7c..4eeb3ab07 100644 --- a/macos-launcher/src/XsollaLib.swift +++ b/macos-launcher/src/XsollaLib.swift @@ -86,9 +86,6 @@ func stagedPatchURL(root: URL, relativePath: String) throws -> URL { } class XsollaUpdateParser: NSObject, XMLParserDelegate { - - var articleNth = 0 - var gameVersion = 0 var actions: [XsollaUpdateAction] = [] @@ -219,6 +216,7 @@ struct XsollaUpdater { throw NSError(domain: "XsollaUpdater", code: 1, userInfo: nil) } + @MainActor func installedVersion() -> Int { do { var gamePath = URL(fileURLWithPath: try self.gamePath()) @@ -231,22 +229,6 @@ struct XsollaUpdater { } } - class UpdateAction { - var type: String - var version: Int - var url: String - - init(type: String, version: Int, url: String) { - self.type = type - self.version = version - self.url = url - } - } - - func latestGameVersion() async throws -> Int { - return try await self.latestGameVersion(installedVersion: self.installedVersion()) - } - private func latestGameVersion(installedVersion: Int) async throws -> Int { let url = URL( string: String( @@ -273,6 +255,7 @@ struct XsollaUpdater { throw NSError(domain: "XsollaUpdater", code: 2, userInfo: nil) } + @MainActor func checkForUpdateAvailable() async -> Bool { do { let installedVersion = self.installedVersion() @@ -287,6 +270,7 @@ struct XsollaUpdater { } } + @MainActor func updateGame(delegate: XSollaUpdaterDelegate? = nil) async throws { let installedVersion = self.installedVersion() logger.info("Starting game update from installed version \(installedVersion, privacy: .public)") @@ -530,8 +514,6 @@ struct XsollaUpdater { logger.warning("Failed to remove Xsolla temp path: \(error.localizedDescription, privacy: .public)") } - logger.info("Update complete, setting flag to force entitlement re-application") - UserDefaults.standard.set(true, forKey: "forceEntitlementReapplication") delegate?.updateProgress(progress: XsollaUpdateProgress.Complete) logger.info( "Game update completed from \(installedVersion, privacy: .public) to \(pendingGameVersion ?? parser.gameVersion, privacy: .public)") @@ -542,7 +524,3 @@ struct XsollaUpdater { } } } - -func run_update() { - // -} diff --git a/macos-launcher/src/rsync.cc b/macos-launcher/src/rsync.cc index 46e50d210..51934f22f 100644 --- a/macos-launcher/src/rsync.cc +++ b/macos-launcher/src/rsync.cc @@ -1,18 +1,26 @@ #include #include +#include extern "C" { int rsync_apply(const char* source_file, const char* patch_file, const char* target_file) { - rs_stats_t stats; - auto basis_file = fopen(source_file, "rb"); - auto delta_file = fopen(patch_file, "rb"); - auto new_file = fopen(target_file, "wb"); + using File = std::unique_ptr; + File basis_file(fopen(source_file, "rb"), fclose); + if (!basis_file) + return RS_IO_ERROR; + File delta_file(fopen(patch_file, "rb"), fclose); + if (!delta_file) + return RS_IO_ERROR; + File new_file(fopen(target_file, "wb"), fclose); + if (!new_file) + return RS_IO_ERROR; - auto result = rs_patch_file(basis_file, delta_file, new_file, &stats); - fclose(basis_file); - fclose(delta_file); - fclose(new_file); - return result; + rs_stats_t stats{}; + auto result = rs_patch_file(basis_file.get(), delta_file.get(), new_file.get(), &stats); + if (result != RS_DONE) + return result; + // Buffered writes can fail only when the output is closed. + return fclose(new_file.release()) == 0 ? RS_DONE : RS_IO_ERROR; } } diff --git a/macos-launcher/src/rsync.swift b/macos-launcher/src/rsync.swift index 7752e0d3c..8b401ef19 100644 --- a/macos-launcher/src/rsync.swift +++ b/macos-launcher/src/rsync.swift @@ -4,6 +4,8 @@ import librsync func rsyncApply(source: URL, patch: URL, output: URL) throws { let err = rsync_apply(source.path, patch.path, output.path) if err != 0 { - throw NSError(domain: NSPOSIXErrorDomain, code: Int(err), userInfo: nil) + throw NSError( + domain: "librsync", code: Int(err), + userInfo: [NSLocalizedDescriptionKey: "Could not apply the game patch (librsync error \(err))"]) } } diff --git a/macos-loader/src/folder_manager.h b/macos-loader/src/folder_manager.h deleted file mode 100644 index 96c6d2547..000000000 --- a/macos-loader/src/folder_manager.h +++ /dev/null @@ -1,55 +0,0 @@ -#pragma once - -namespace fm -{ -enum { - NSApplicationDirectory = 1, - NSDemoApplicationDirectory, - NSDeveloperApplicationDirectory, - NSAdminApplicationDirectory, - NSLibraryDirectory, - NSDeveloperDirectory, - NSUserDirectory, - NSDocumentationDirectory, - NSDocumentDirectory, - NSCoreServiceDirectory, - NSAutosavedInformationDirectory = 11, - NSDesktopDirectory = 12, - NSCachesDirectory = 13, - NSApplicationSupportDirectory = 14, - NSDownloadsDirectory = 15, - NSInputMethodsDirectory = 16, - NSMoviesDirectory = 17, - NSMusicDirectory = 18, - NSPicturesDirectory = 19, - NSPrinterDescriptionDirectory = 20, - NSSharedPublicDirectory = 21, - NSPreferencePanesDirectory = 22, - NSApplicationScriptsDirectory = 23, - NSItemReplacementDirectory = 99, - NSAllApplicationsDirectory = 100, - NSAllLibrariesDirectory = 101, - NSTrashDirectory = 102 -}; -typedef unsigned long SearchPathDirectory; - -enum { - NSUserDomainMask = 1, // user's home directory --- place to install user's personal items (~) - NSLocalDomainMask = - 2, // local to the current machine --- place to install items available to everyone on this machine (/Library) - NSNetworkDomainMask = 4, // publically available location in the local area network --- place to install items - // available on the network (/Network) - NSSystemDomainMask = 8, // provided by Apple, unmodifiable (/System) - NSAllDomainsMask = 0x0ffff // all domains: all of the above and future items -}; -typedef unsigned long SearchPathDomainMask; - -class FolderManager -{ -public: - static const char *pathForDirectory(SearchPathDirectory directory, SearchPathDomainMask domainMask); - static const char *pathForDirectoryAppropriateForItemAtPath(SearchPathDirectory directory, - SearchPathDomainMask domainMask, const char *itemPath, - bool create = false); -}; -}; // namespace fm diff --git a/macos-loader/src/folder_manager.mm b/macos-loader/src/folder_manager.mm deleted file mode 100644 index 33ec3ca32..000000000 --- a/macos-loader/src/folder_manager.mm +++ /dev/null @@ -1,31 +0,0 @@ -#include "folder_manager.h" - -#import - -using namespace fm; - -const char * FolderManager::pathForDirectory(SearchPathDirectory directory, SearchPathDomainMask domainMask) { - NSFileManager *fileManager = [NSFileManager defaultManager]; - NSArray *URLs = [fileManager URLsForDirectory:(NSSearchPathDirectory)directory inDomains:domainMask]; - if (URLs.count == 0) return NULL; - - NSURL *URL = [URLs objectAtIndex:0]; - NSString *path = URL.path; - - // `fileSystemRepresentation` on an `NSString` gives a path suitable for POSIX APIs - return path.fileSystemRepresentation; -} - -const char * FolderManager::pathForDirectoryAppropriateForItemAtPath(SearchPathDirectory directory, - SearchPathDomainMask domainMask, const char *itemPath, bool create) { - - NSFileManager *fileManager = [NSFileManager defaultManager]; - NSString *nsPath = [fileManager stringWithFileSystemRepresentation:itemPath length:strlen(itemPath)]; - NSURL *itemURL = (nsPath ? [NSURL fileURLWithPath:nsPath] : nil); - - NSURL *URL = [fileManager URLForDirectory:(NSSearchPathDirectory)directory - inDomain:domainMask - appropriateForURL:itemURL - create:create error:NULL]; - return URL.path.fileSystemRepresentation; -} \ No newline at end of file diff --git a/macos-loader/src/library_dir.mm b/macos-loader/src/library_dir.mm new file mode 100644 index 000000000..96539f246 --- /dev/null +++ b/macos-loader/src/library_dir.mm @@ -0,0 +1,15 @@ +#include + +#import + +// ~/Library for the current user. Copied out of the autoreleased NSURL before the pool drains. +std::filesystem::path LibraryDirectory() +{ + @autoreleasepool { + NSArray* urls = [[NSFileManager defaultManager] URLsForDirectory:NSLibraryDirectory + inDomains:NSUserDomainMask]; + if (urls.count == 0) + return std::filesystem::path(NSHomeDirectory().fileSystemRepresentation) / "Library"; + return urls.firstObject.path.fileSystemRepresentation; + } +} diff --git a/macos-loader/src/main.cc b/macos-loader/src/main.cc index 08e19a946..cee7803e2 100644 --- a/macos-loader/src/main.cc +++ b/macos-loader/src/main.cc @@ -1,78 +1,73 @@ -#include "folder_manager.h" +// Launches the game with the community mod dylib injected. The game's install path comes from the Xsolla +// launcher's settings; the dylib sits next to this executable. #include -#include -#include -#include -#include #include #include +#include +#include +#include +#include +#include #include #include #include -#include - -#define LD_LIBRARY_PATH_ENV "DYLD_LIBRARY_PATH" -#define LD_PRELOAD_ENV "DYLD_INSERT_LIBRARIES" +std::filesystem::path LibraryDirectory(); // library_dir.mm -static char **appArgs = NULL; +namespace +{ +constexpr auto kAppPath = "Star Trek Fleet Command.app/Contents/MacOS/Star Trek Fleet Command"; -static void die(const char *op) +int Fail(const std::string& message) { - fprintf(stderr, "cliloader Error: %s\n", op); - exit(1); + fprintf(stderr, "stfc-community-mod-loader: %s\n", message.c_str()); + return 1; } -int main() +// `value` prefixed to the variable's current value, colon-separated. +void Prepend(const char* variable, const std::string& value) { - constexpr auto kAppPath = R"(Star Trek Fleet Command.app/Contents/MacOS/Star Trek Fleet Command)"; - - auto ApplicationSupportPath = - (char *)fm::FolderManager::pathForDirectory(fm::NSApplicationSupportDirectory, fm::NSUserDomainMask); - auto LibraryPath = (char *)fm::FolderManager::pathForDirectory(fm::NSLibraryDirectory, fm::NSUserDomainMask); - - std::filesystem::path launcher_settings_ini_path = - std::filesystem::path(LibraryPath) / "Preferences" / "Star Trek Fleet Command" / "launcher_settings.ini"; - - std::ifstream launcher_settings_ini_stream(launcher_settings_ini_path); - ini::IniFile launcher_settings_ini(launcher_settings_ini_stream); - - std::filesystem::path game_install_path = launcher_settings_ini["General"]["152033..GAME_PATH"].as(); - game_install_path /= kAppPath; - auto game_install_path_str = game_install_path.string(); - - char buf[PATH_MAX]; - uint32_t bufsize = PATH_MAX; - _NSGetExecutablePath(buf, &bufsize); - - std::string path = dirname(buf); - -#define SETENV(_name, _value) setenv(_name, _value, 1) - std::string ld_library_path = path; - const char *old_ld_library_path = getenv(LD_LIBRARY_PATH_ENV); - if (old_ld_library_path) { - ld_library_path += ":"; - ld_library_path += old_ld_library_path; - } - std::string ld_preload = path + "/libstfc-community-mod.dylib"; - const char *old_ld_preload = getenv(LD_PRELOAD_ENV); - if (old_ld_preload) { - ld_preload += ":"; - ld_preload += old_ld_preload; - } - - SETENV(LD_PRELOAD_ENV, ld_preload.c_str()); - SETENV(LD_LIBRARY_PATH_ENV, ld_library_path.c_str()); - - char *const argument_list[] = {(char *)game_install_path_str.c_str(), NULL}; - - if (execvp(argument_list[0], argument_list) == -1) { - printf("Failed to launch app %d %s\n", errno, game_install_path_str.c_str()); - return -1; + std::string combined = value; + if (const char* old = getenv(variable)) { + combined += ":"; + combined += old; } + setenv(variable, combined.c_str(), 1); +} +} // namespace - return 0; +int main() +{ + const auto settings_path = LibraryDirectory() / "Preferences" / "Star Trek Fleet Command" / "launcher_settings.ini"; + std::ifstream settings_stream(settings_path); + if (!settings_stream) + return Fail("cannot read " + settings_path.string() + "; install the game with the Xsolla launcher first"); + ini::IniFile settings(settings_stream); + + const auto install_dir = settings["General"]["152033..GAME_PATH"].as(); + if (install_dir.empty()) + return Fail("no game path (152033..GAME_PATH) in " + settings_path.string()); + const auto game = (std::filesystem::path(install_dir) / kAppPath).string(); + + // Resolve symlinks so the dylib path is the real one next to this binary. + char executable[PATH_MAX]; + uint32_t size = sizeof(executable); + if (_NSGetExecutablePath(executable, &size) != 0) + return Fail("executable path does not fit"); + char resolved[PATH_MAX]; + if (!realpath(executable, resolved)) + return Fail(std::string("cannot resolve executable path: ") + strerror(errno)); + const auto loader_dir = std::filesystem::path(resolved).parent_path().string(); + + Prepend("DYLD_INSERT_LIBRARIES", loader_dir + "/libstfc-community-mod.dylib"); + // TODO: likely unnecessary since the inserted path is absolute, but it leaks into every lookup the game makes. + // Remove once someone confirms the game still starts without it. + Prepend("DYLD_LIBRARY_PATH", loader_dir); + + char* const arguments[] = {const_cast(game.c_str()), nullptr}; + execv(arguments[0], arguments); + return Fail("failed to launch " + game + ": " + strerror(errno)); } diff --git a/tests/launcher_rsync.cc b/tests/launcher_rsync.cc new file mode 100644 index 000000000..b8ed1f165 --- /dev/null +++ b/tests/launcher_rsync.cc @@ -0,0 +1,77 @@ +// The launcher bridge must report file errors and preserve librsync's patch result. +#include + +#include +#include +#include +#include +#include +#include + +extern "C" int rsync_apply(const char*, const char*, const char*); + +int main() +{ + namespace fs = std::filesystem; + const auto root = fs::temp_directory_path() + / ("stfc-rsync-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + fs::create_directory(root); + struct Cleanup { + fs::path root; + ~Cleanup() + { fs::remove_all(root); } + } cleanup{root}; + int failures = 0; + const auto check = [&](bool ok, const char* message) { + if (!ok) { + std::cerr << message << '\n'; + ++failures; + } + }; + const auto basis = (root / "basis").string(), patch = (root / "patch").string(); + const auto output = (root / "output").string(), missing = (root / "missing").string(); + const std::string old_text(4096, 'a'); + const auto new_text = old_text + " changed tail\n"; + std::ofstream(basis) << old_text; + + // Generate a real delta with copy and literal data, then check the bridge against the expected file. + using File = std::unique_ptr; + File source(fopen(basis.c_str(), "rb"), fclose), signature(tmpfile(), fclose), updated(tmpfile(), fclose); + File delta(fopen(patch.c_str(), "wb"), fclose); + if (!source || !signature || !updated || !delta) + return 1; + if (rs_sig_file(source.get(), signature.get(), 0, 0, RS_BLAKE2_SIG_MAGIC, nullptr) != RS_DONE) + return 1; + rewind(signature.get()); + rs_signature_t* sums = nullptr; + if (rs_loadsig_file(signature.get(), &sums, nullptr) != RS_DONE) + return 1; + std::unique_ptr owned_sums(sums, rs_free_sumset); + if (rs_build_hash_table(sums) != RS_DONE) + return 1; + fwrite(new_text.data(), 1, new_text.size(), updated.get()); + rewind(updated.get()); + if (rs_delta_file(sums, updated.get(), delta.get(), nullptr) != RS_DONE) + return 1; + delta.reset(); + + check(rsync_apply(missing.c_str(), patch.c_str(), output.c_str()) == RS_IO_ERROR, "missing basis reports IO error"); + check(!fs::exists(output), "missing basis does not create output"); + check(rsync_apply(basis.c_str(), missing.c_str(), output.c_str()) == RS_IO_ERROR, "missing delta reports IO error"); + check(!fs::exists(output), "missing delta does not create output"); + check(rsync_apply(basis.c_str(), patch.c_str(), root.string().c_str()) == RS_IO_ERROR, + "unwritable output reports IO error"); + check(rsync_apply(basis.c_str(), patch.c_str(), output.c_str()) == RS_DONE, "valid patch succeeds"); + std::ifstream result(output, std::ios::binary); + check(std::string(std::istreambuf_iterator(result), {}) == new_text, "patch reconstructs expected content"); + +#if __linux__ + // A small patch can fit in stdio's buffer, so the write error may surface only at fclose. + check(rsync_apply(basis.c_str(), patch.c_str(), "/dev/full") == RS_IO_ERROR, "output flush error is propagated"); +#endif + std::ofstream(patch, std::ios::binary | std::ios::trunc) << "invalid delta"; + check(rsync_apply(basis.c_str(), patch.c_str(), output.c_str()) != RS_DONE, "invalid delta reports failure"); + if (!failures) + std::cout << "Launcher rsync bridge tests passed\n"; + return failures ? 1 : 0; +} diff --git a/xmake.lua b/xmake.lua index a181d344d..fa30f9877 100644 --- a/xmake.lua +++ b/xmake.lua @@ -104,3 +104,11 @@ target("config-reload-tests") elseif is_plat("windows") then add_syslinks("shell32", "winmm") end + +target("launcher-rsync-tests") + set_kind("binary") + set_default(false) + set_exceptions("cxx") + add_files("tests/launcher_rsync.cc", "macos-launcher/src/rsync.cc") + add_includedirs("macos-launcher/src") + add_packages("librsync")