diff --git a/.github/workflows/binary.yml b/.github/workflows/binary.yml index 928b750..f42d969 100644 --- a/.github/workflows/binary.yml +++ b/.github/workflows/binary.yml @@ -16,7 +16,11 @@ jobs: with: toolchain: stable target: x86_64-unknown-linux-musl - + + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Build release binaries run: | rustup target add x86_64-unknown-linux-musl diff --git a/.github/workflows/release-bin.yml b/.github/workflows/release-bin.yml index 2d4cfd5..f5db70e 100644 --- a/.github/workflows/release-bin.yml +++ b/.github/workflows/release-bin.yml @@ -17,7 +17,11 @@ jobs: with: toolchain: stable target: x86_64-unknown-linux-musl - + + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Build release binaries run: | rustup target add x86_64-unknown-linux-musl diff --git a/Cargo.toml b/Cargo.toml index 523901d..27825c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "boson" -version = "0.2.1" +version = "0.3.0" edition = "2021" license = "MIT" authors = ["Pornpipat Popum "] +publish = false description = "Run Electron Steam games natively" readme = "README.md" repository = "https://github.com/FyraLabs/boson" diff --git a/Makefile b/Makefile index 084bf2e..bc37592 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ DESTDIR=$(PWD)/build CRATE_DIR=$(PWD) TARGET_DIR=$(CRATE_DIR)/target CRATE_NAME=boson -CARGO_ARGS="" +CARGO_ARGS= CARGO_TARGET="release" .PHONY: build build-dev @@ -20,6 +20,7 @@ pack-release: prep build cp $(TARGET_DIR)/$(CARGO_TARGET)/$(CRATE_NAME) $(DESTDIR)/$(CRATE_NAME) @echo "Copying assets" cp -av $(CRATE_DIR)/assets/. $(DESTDIR) + pushd $(DESTDIR) && npm install && popd pack-dev: prep build-dev ln -fv $(TARGET_DIR)/debug/$(CRATE_NAME) $(DESTDIR)/$(CRATE_NAME) diff --git a/assets/.gitignore b/assets/.gitignore new file mode 100644 index 0000000..d5f19d8 --- /dev/null +++ b/assets/.gitignore @@ -0,0 +1,2 @@ +node_modules +package-lock.json diff --git a/assets/lib/.gitignore b/assets/lib/.gitignore new file mode 100644 index 0000000..1a7d329 --- /dev/null +++ b/assets/lib/.gitignore @@ -0,0 +1 @@ +napi/* \ No newline at end of file diff --git a/assets/lib/greenworks.js b/assets/lib/greenworks.js new file mode 100755 index 0000000..7817ea0 --- /dev/null +++ b/assets/lib/greenworks.js @@ -0,0 +1,221 @@ +// Copyright (c) 2015 Greenheart Games Pty. Ltd. All rights reserved. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +// The source code can be found in https://github.com/greenheartgames/greenworks + +// ============================== +// Boson notes +// ============================== +// The following file is vendored from the greenworks project, slightly modified to work with Boson's Greenworks hooks. + +var fs = require('fs'); + +var greenworks; + +if (process.platform == 'darwin') { + if (process.arch == 'x64') + greenworks = require(__dirname + '/lib/greenworks-osx64'); +} else if (process.platform == 'win32') { + if (process.arch == 'x64') + greenworks = require(__dirname + '/lib/greenworks-win64'); + else if (process.arch == 'ia32') + greenworks = require(__dirname + '/lib/greenworks-win32'); +} else if (process.platform == 'linux') { + if (process.arch == 'x64') + greenworks = require(__dirname + '/lib/greenworks-linux64'); + else if (process.arch == 'ia32') + greenworks = require(__dirname + '/lib/greenworks-linux32'); +} + +function error_process(err, error_callback) { + if (err && error_callback) + error_callback(err); +} + +greenworks.ugcGetItems = function(options, ugc_matching_type, ugc_query_type, + success_callback, error_callback) { + if (typeof options !== 'object') { + error_callback = success_callback; + success_callback = ugc_query_type; + ugc_query_type = ugc_matching_type; + ugc_matching_type = options; + options = { + 'app_id': greenworks.getAppId(), + 'page_num': 1 + } + } + greenworks._ugcGetItems(options, ugc_matching_type, ugc_query_type, + success_callback, error_callback); +} + +greenworks.ugcGetUserItems = function(options, ugc_matching_type, + ugc_list_sort_order, ugc_list, success_callback, error_callback) { + if (typeof options !== 'object') { + error_callback = success_callback; + success_callback = ugc_list; + ugc_list = ugc_list_sort_order; + ugc_list_sort_order = ugc_matching_type; + ugc_matching_type = options; + options = { + 'app_id': greenworks.getAppId(), + 'page_num': 1 + } + } + greenworks._ugcGetUserItems(options, ugc_matching_type, ugc_list_sort_order, + ugc_list, success_callback, error_callback); +} + +greenworks.ugcSynchronizeItems = function (options, sync_dir, success_callback, + error_callback) { + if (typeof options !== 'object') { + error_callback = success_callback; + success_callback = sync_dir; + sync_dir = options; + options = { + 'app_id': greenworks.getAppId(), + 'page_num': 1 + } + } + greenworks._ugcSynchronizeItems(options, sync_dir, success_callback, + error_callback); +} + +greenworks.publishWorkshopFile = function(options, file_path, image_path, title, + description, success_callback, error_callback) { + if (typeof options !== 'object') { + error_callback = success_callback; + success_callback = description; + description = title; + title = image_path; + image_path = file_path; + file_path = options; + options = { + 'app_id': greenworks.getAppId(), + 'tags': [] + } + } + greenworks._publishWorkshopFile(options, file_path, image_path, title, + description, success_callback, error_callback); +} + +greenworks.updatePublishedWorkshopFile = function(options, + published_file_handle, file_path, image_path, title, description, + success_callback, error_callback) { + if (typeof options !== 'object') { + error_callback = success_callback; + success_callback = description; + description = title; + title = image_path; + image_path = file_path; + file_path = published_file_handle; + published_file_handle = options; + options = { + 'tags': [] // No tags are set + } + } + greenworks._updatePublishedWorkshopFile(options, published_file_handle, + file_path, image_path, title, description, success_callback, + error_callback); +} + +// An utility function for publish related APIs. +// It processes remains steps after saving files to Steam Cloud. +function file_share_process(file_name, image_name, next_process_func, + error_callback, progress_callback) { + if (progress_callback) + progress_callback("Completed on saving files on Steam Cloud."); + greenworks.fileShare(file_name, function() { + greenworks.fileShare(image_name, function() { + next_process_func(); + }, function(err) { error_process(err, error_callback); }); + }, function(err) { error_process(err, error_callback); }); +} + +// Publishing user generated content(ugc) to Steam contains following steps: +// 1. Save file and image to Steam Cloud. +// 2. Share the file and image. +// 3. publish the file to workshop. +greenworks.ugcPublish = function(file_name, title, description, image_name, + success_callback, error_callback, progress_callback) { + var publish_file_process = function() { + if (progress_callback) + progress_callback("Completed on sharing files."); + greenworks.publishWorkshopFile(file_name, image_name, title, description, + function(publish_file_id) { success_callback(publish_file_id); }, + function(err) { error_process(err, error_callback); }); + }; + greenworks.saveFilesToCloud([file_name, image_name], function() { + file_share_process(file_name, image_name, publish_file_process, + error_callback, progress_callback); + }, function(err) { error_process(err, error_callback); }); +} + +// Update publish ugc steps: +// 1. Save new file and image to Steam Cloud. +// 2. Share file and images. +// 3. Update published file. +greenworks.ugcPublishUpdate = function(published_file_id, file_name, title, + description, image_name, success_callback, error_callback, + progress_callback) { + var update_published_file_process = function() { + if (progress_callback) + progress_callback("Completed on sharing files."); + greenworks.updatePublishedWorkshopFile(published_file_id, + file_name, image_name, title, description, + function() { success_callback(); }, + function(err) { error_process(err, error_callback); }); + }; + + greenworks.saveFilesToCloud([file_name, image_name], function() { + file_share_process(file_name, image_name, update_published_file_process, + error_callback, progress_callback); + }, function(err) { error_process(err, error_callback); }); +} + +// Greenworks Utils APIs implmentation. +greenworks.Utils.move = function(source_dir, target_dir, success_callback, + error_callback) { + fs.rename(source_dir, target_dir, function(err) { + if (err) { + if (error_callback) error_callback(err); + return; + } + if (success_callback) + success_callback(); + }); +} + +greenworks.init = function() { + if (this.initAPI()) return true; + if (!this.isSteamRunning()) + throw new Error("Steam initialization failed. Steam is not running."); + var appId; + try { + appId = fs.readFileSync('steam_appid.txt', 'utf8'); + } catch (e) { + throw new Error("Steam initialization failed. Steam is running," + + "but steam_appid.txt is missing. Expected to find it in: " + + require('path').resolve('steam_appid.txt')); + } + if (!/^\d+ *\r?\n?$/.test(appId)) { + throw new Error("Steam initialization failed. " + + "steam_appid.txt appears to be invalid; " + + "it should contain a numeric ID: " + appId); + } + throw new Error("Steam initialization failed, but Steam is running, " + + "and steam_appid.txt is present and valid." + + "Maybe that's not really YOUR app ID? " + appId.trim()); +} + +var EventEmitter = require('events').EventEmitter; +greenworks.__proto__ = EventEmitter.prototype; +EventEmitter.call(greenworks); + +greenworks._steam_events.on = function () { + greenworks.emit.apply(greenworks, arguments); +}; + +process.versions['greenworks'] = greenworks._version; + +module.exports = greenworks; diff --git a/assets/lib/libsdkencryptedappticket.so b/assets/lib/libsdkencryptedappticket.so new file mode 100644 index 0000000..5228c96 Binary files /dev/null and b/assets/lib/libsdkencryptedappticket.so differ diff --git a/assets/lib/libsteam_api.so b/assets/lib/libsteam_api.so new file mode 100644 index 0000000..99c9164 Binary files /dev/null and b/assets/lib/libsteam_api.so differ diff --git a/assets/package.json b/assets/package.json new file mode 100644 index 0000000..2ab5c9d --- /dev/null +++ b/assets/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "override-require": "^1.1.1" + } +} diff --git a/assets/register-hook.js b/assets/register-hook.js new file mode 100644 index 0000000..ebe41af --- /dev/null +++ b/assets/register-hook.js @@ -0,0 +1,142 @@ +// Hook to re-register the greenworks module + +console.log("BOSON: Hooking Greenworks"); + +console.debug("Attempting to override NAPI module path"); + +// const { pathToFileURL } = require("node:url"); +const overrideRequire = require("override-require"); + +// https://github.com/ElectronForConstruct/greenworks-prebuilds/releases/download/v0.8.0/greenworks-electron-v125-linux-x64.node + +// We are going to do the hook twice + +// First hook pass: Replace the NAPI module path, so that it points to the correct NAPI module for the current Electron ABI + +function getElectronAbi() { + const absoluteElectronPath = process.execPath; + // try to run electron -a and get output + const { execSync } = require("node:child_process"); + const electronAbi = execSync(`${absoluteElectronPath} -a`) + .toString() + .trim(); + console.debug("Electron ABI:", electronAbi); + return electronAbi; +} +let napi; +const napiDir = __dirname + "/lib/napi"; + +let napi_filename = `greenworks-electron-v${getElectronAbi()}-linux-x64.node`; + +let napiPath = `${napiDir}/${napi_filename}`; +// let napiPath = `${napiDir}/greenworks-electron-v125-linux-x64.node`; + +// strip .node extension +let napiPathNoExt = napiPath.replace(/\.node$/, ""); + +function http_get(url) { + // recursive function that follows redirects + const http = require("node:https"); + const fs = require("node:fs"); + + const file = fs.createWriteStream(napiPath); + + const request = http.get(url, function (response) { + if ( + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + console.log("Following redirect to:", response.headers.location); + http_get(response.headers.location); + } else { + response.pipe(file); + } + + file.on("finish", function () { + file.close(); + console.log("Download complete."); + console.log( + "You may want to restart the game before Greenworks will work.", + ); + napi = require(napiPathNoExt); + }); + }); +} + +function attempt_download_napi() { + const http = require("node:https"); + const fs = require("node:fs"); + + // check if file already exists + fs.mkdirSync(napiDir, { recursive: true }); + if (fs.existsSync(napiPath)) { + console.log("NAPI file already exists. Skipping download."); + return; + } + + try { + // recursively create the directory + + const url = `https://github.com/ElectronForConstruct/greenworks-prebuilds/releases/download/v0.8.0/${napi_filename}`; + + const file = fs.createWriteStream(napiPath); + + const request = http_get(url); + } catch (e) { + console.error("Failed to download NAPI file:", e); + exit(1); + } +} + +attempt_download_napi(); + +try { + console.log("Loading", napiPathNoExt); + napi = require(napiPathNoExt); + // console.log("Loaded NAPI module:", napi); +} catch (e) { + console.error(e); +} + +const isOverride1 = (request, parent) => { + // console.debug("PASS 1 CHECKING:", request); + return request.includes("lib/greenworks-linux64"); +}; + +const resolveRequest1 = (request, parent) => { + console.debug("PASS 1 REQUEST:", request); + return napi; +}; + +const restoreOverride1 = overrideRequire(isOverride1, resolveRequest1); + +let greenworks; +try { + greenworks = require(__dirname + "/lib/greenworks"); +} catch (e) { + console.error(e); +} + +restoreOverride1(); + +// console.log("Greenworks:", greenworks); + +// === END FIRST PASS === + +// Second hook pass: Replace the actual game import for greenworks, so that it points to our custom greenworks module + +const isOverride = (request) => { + console.debug("Checking:", request); + return request.includes("greenworks/greenworks"); + // return false; +}; + +const resolveRequest = (request) => { + console.debug("Request:", request); + let out = greenworks; + + return out; +}; + +overrideRequire(isOverride, resolveRequest); diff --git a/assets/toolmanifest.vdf b/assets/toolmanifest.vdf index 57e326b..feddbc0 100644 --- a/assets/toolmanifest.vdf +++ b/assets/toolmanifest.vdf @@ -4,4 +4,6 @@ "commandline_waitforexitandrun" "/boson run" "commandline_getnativepath" "/boson path" "commandline_getcompatpath" "/boson path" + "compatmanager_layer_name" "container-runtime" + "require_tool_appid" "1391110" } diff --git a/justfile b/justfile index c27c386..f059d0a 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,5 @@ set dotenv-load run: - cargo r -- run "$GAME_PATH" $GAME_ARGS \ No newline at end of file + make pack-dev + $PWD/build/boson run -- "$GAME_PATH" $GAME_ARGS \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index e2a299a..c4052ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,16 @@ fn main() -> Result<()> { std::env::args().collect::>() ); let args = Boson::parse(); + let exec_path = std::env::current_exe()?; + + // get the folder of the executable + let exec_dir = exec_path.parent().unwrap(); + + tracing::info!("Executable path: {:?}", exec_path); + tracing::info!("Executable directory: {:?}", exec_dir); + + // Create path for hook + let hook_path = exec_dir.join("register-hook.js"); match args.cmd { Commands::Run { @@ -58,25 +68,55 @@ fn main() -> Result<()> { } => { let electron = path_search::env_electron_path(); - let mut args = vec![]; + let mut args = vec![ + "--no-sandbox" + ]; let gpath = path_search::get_game_path(&game_path); // Actually get the game executable path here let app_path_str = get_asar_path(&game_path).ok_or_eyre( "Could not find ASAR file in game directory. Make sure you're running this from the game directory.", )?; + + // todo: path to boson hook + let load_hook_arg = vec!["--require", hook_path.to_str().unwrap()]; + + // Add the args before the app path + args.extend(load_hook_arg.iter()); + args.extend(additional_args.iter().map(|s| s.as_str())); args.push(app_path_str.to_str().unwrap()); tracing::info!(?gpath); - args.extend(additional_args.iter().map(|s| s.as_str())); - tracing::debug!(?args); + + // Remove steam overlay from LD_PRELOAD + + let ld_preload = std::env::var("LD_PRELOAD").unwrap_or_default(); + // shadow the variable + // + // filter out the gameoverlayrenderer + let ld_preload = std::env::split_paths(&ld_preload).filter(|x| { + x.to_str().map(|x| !x.contains("gameoverlayrenderer")).unwrap_or(true) + }).collect::>(); + + let ld_preload = std::env::join_paths(ld_preload).unwrap(); + + + let ld_library_path = std::env::var("LD_LIBRARY_PATH").unwrap_or_default(); + let mut ld_library_path = std::env::split_paths(&ld_library_path).collect::>(); + + // add the exec_dir/lib to the LD_LIBRARY_PATH + + ld_library_path.push(exec_dir.join("lib")); + + let ld_library_path = std::env::join_paths(ld_library_path).unwrap(); let mut cmd = Command::new(electron); cmd.current_dir(&gpath) + .env("LD_LIBRARY_PATH", ld_library_path) // Do not preload any libraries, hack to fix Steam overlay - .env_remove("LD_PRELOAD") + .env("LD_PRELOAD", ld_preload) .args(args); let c = cmd.spawn()?.wait(); diff --git a/src/path_search.rs b/src/path_search.rs index 47dc44c..3bd396c 100644 --- a/src/path_search.rs +++ b/src/path_search.rs @@ -76,13 +76,30 @@ fn package_json_scan(path: &Path) { } } +fn _compat_data_path() -> Option { + std::env::var("STEAM_COMPAT_DATA_PATH") + .ok() + .map(|s| PathBuf::from(s).join("boson")) + .map(|p| p.to_str().unwrap().to_string()) +} + /// Get ASAR path /// /// Accepts a game root directory, usually from `get_game_path()` /// and returns the path to the ASAR #[tracing::instrument] pub fn get_asar_path(game_exec_path: &Path) -> Option { - let game_path = get_game_path(game_exec_path); + let game_path = { + if let Ok(path) = std::env::var("STEAM_COMPAT_INSTALL_PATH") { + tracing::info!("STEAM_COMPAT_INSTALL_PATH found: {:?}", path); + path.into() + } + // If the game path is not provided, use the game executable path + else { + get_game_path(game_exec_path) + } + }; + tracing::trace!("Game path: {:?}", game_path); // First check if there's an override in the environment if let Some(path) = env_boson_load_path() {