From 0694ee98fafa0438cb42128f975d5f1d66fa3c80 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 15 Oct 2026 16:40:42 -0700 Subject: [PATCH 1/2] Report macOS loader failures out loud The loader kept a pointer into an autoreleased string, ignored a missing settings file or game path, then executed a relative path. Each case now fails with a message on stderr. Symlinks are resolved, so launching through a link no longer injects a dylib path that does not exist. --- macos-loader/src/folder_manager.h | 55 -------------- macos-loader/src/folder_manager.mm | 31 -------- macos-loader/src/library_dir.mm | 15 ++++ macos-loader/src/main.cc | 113 ++++++++++++++--------------- 4 files changed, 69 insertions(+), 145 deletions(-) delete mode 100644 macos-loader/src/folder_manager.h delete mode 100644 macos-loader/src/folder_manager.mm create mode 100644 macos-loader/src/library_dir.mm 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)); } From 3f7464f600f19e2f2a779e6295dba43ae276ba25 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 15 Oct 2026 16:40:42 -0700 Subject: [PATCH 2/2] Simplify launcher update handling The launcher uses the Xsolla updater directly and drops unused view state. Required entitlements are compared without redundant reapplication flags, and UI work stays on the main actor. The rsync bridge owns its patch handles and reports read, write, patch and close failures instead of dropping them. --- macos-launcher/src/ActionView.swift | 11 ++- macos-launcher/src/ContentView.swift | 17 +--- macos-launcher/src/GitHubLib.swift | 16 ---- macos-launcher/src/ManagedURL.swift | 14 ---- macos-launcher/src/PatchEntitlements.swift | 66 +--------------- macos-launcher/src/TemporaryFolderURL.swift | 2 +- .../src/ViewModel/GameUpdaterViewModel.swift | 28 ------- macos-launcher/src/XsollaLib.swift | 28 +------ macos-launcher/src/rsync.cc | 26 ++++--- macos-launcher/src/rsync.swift | 4 +- tests/launcher_rsync.cc | 77 +++++++++++++++++++ xmake.lua | 8 ++ 12 files changed, 119 insertions(+), 178 deletions(-) delete mode 100644 macos-launcher/src/GitHubLib.swift delete mode 100644 macos-launcher/src/ManagedURL.swift delete mode 100644 macos-launcher/src/ViewModel/GameUpdaterViewModel.swift create mode 100644 tests/launcher_rsync.cc 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/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")