aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xdocker/build-inner.sh3
-rw-r--r--libs/nak/src/lib.rs1
-rw-r--r--libs/nak/src/slr.rs259
-rw-r--r--libs/nak_ffi/include/nak_ffi.h26
-rw-r--r--libs/nak_ffi/src/lib.rs76
-rw-r--r--src/src/CMakeLists.txt3
-rw-r--r--src/src/executableslist.cpp2
-rw-r--r--src/src/filetree.cpp3
-rw-r--r--src/src/instancemanagerdialog.cpp92
-rw-r--r--src/src/instancemanagerdialog.h5
-rw-r--r--src/src/instancemanagerdialog.ui13
-rw-r--r--src/src/mainwindow.cpp52
-rw-r--r--src/src/protonlauncher.cpp53
-rw-r--r--src/src/protonlauncher.h2
-rw-r--r--src/src/settingsdialog.ui20
-rw-r--r--src/src/settingsdialogproton.cpp57
-rw-r--r--src/src/settingsdialogproton.h1
-rw-r--r--src/src/spawn.cpp9
18 files changed, 663 insertions, 14 deletions
diff --git a/docker/build-inner.sh b/docker/build-inner.sh
index 42fa4c7..62232d9 100755
--- a/docker/build-inner.sh
+++ b/docker/build-inner.sh
@@ -414,7 +414,10 @@ export MO2_DLLS_DIR="${RUN}/dlls"
unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME MO2_PYTHON_DIR
# Use bundled Qt6 plugins.
+# Set both vars: QT_QPA_PLATFORM_PLUGIN_PATH is highest priority for platform
+# plugin lookup and overrides system-wide qt.conf (e.g. Fedora's /etc/xdg/QtProject/).
export QT_PLUGIN_PATH="${RUN}/qt6plugins"
+export QT_QPA_PLATFORM_PLUGIN_PATH="${RUN}/qt6plugins/platforms"
cd "${RUN}"
exec "${RUN}/ModOrganizer-core" "$@"
diff --git a/libs/nak/src/lib.rs b/libs/nak/src/lib.rs
index 13bc90e..d6c8e2b 100644
--- a/libs/nak/src/lib.rs
+++ b/libs/nak/src/lib.rs
@@ -5,6 +5,7 @@
pub mod config;
pub mod dxvk;
+pub mod slr;
pub mod game_finder;
pub mod logging;
pub mod paths;
diff --git a/libs/nak/src/slr.rs b/libs/nak/src/slr.rs
new file mode 100644
index 0000000..30dca38
--- /dev/null
+++ b/libs/nak/src/slr.rs
@@ -0,0 +1,259 @@
+//! Steam Linux Runtime (SLR) download and management for Fluorine Manager.
+//!
+//! Downloads SteamLinuxRuntime_sniper from Valve's official repo and stores it
+//! at `~/.local/share/fluorine/steamrt/`. The `run` script is then used to
+//! wrap game launches inside the pressure-vessel container, providing
+//! GStreamer, 32-bit libs, and an FHS-compliant environment for non-FHS
+//! distros (NixOS, etc.).
+
+use std::error::Error;
+use std::fs;
+use std::io::{self, Read, Write};
+use std::path::PathBuf;
+use std::process::Command;
+use std::sync::atomic::{AtomicI32, Ordering};
+
+use crate::logging::{log_info, log_warning};
+
+const BASE_URL: &str =
+ "https://repo.steampowered.com/steamrt3/images/latest-public-beta";
+const ARCHIVE_NAME: &str = "SteamLinuxRuntime_sniper.tar.xz";
+const EXTRACTED_DIR: &str = "SteamLinuxRuntime_sniper";
+
+/// Directory where SLR is installed: `~/.local/share/fluorine/steamrt/`
+pub fn slr_install_dir() -> PathBuf {
+ crate::paths::data_dir().join("steamrt")
+}
+
+/// Path to the `run` script inside the extracted SLR.
+pub fn slr_run_script() -> PathBuf {
+ slr_install_dir().join(EXTRACTED_DIR).join("run")
+}
+
+/// Path where we store the remote BUILD_ID for update checks.
+fn local_build_id_path() -> PathBuf {
+ slr_install_dir().join("BUILD_ID.txt")
+}
+
+/// Returns true if the SLR `run` script is present and executable.
+pub fn is_slr_installed() -> bool {
+ let script = slr_run_script();
+ if !script.exists() {
+ return false;
+ }
+ // Verify it's actually executable
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ if let Ok(meta) = fs::metadata(&script) {
+ return meta.permissions().mode() & 0o111 != 0;
+ }
+ return false;
+ }
+ #[cfg(not(unix))]
+ true
+}
+
+/// Returns the path to the `run` script, or None if SLR is not installed.
+pub fn get_slr_run_script() -> Option<PathBuf> {
+ if is_slr_installed() {
+ Some(slr_run_script())
+ } else {
+ None
+ }
+}
+
+/// Fetch the remote BUILD_ID as a string.
+fn fetch_remote_build_id() -> Result<String, Box<dyn Error>> {
+ let url = format!("{}/BUILD_ID.txt", BASE_URL);
+ let resp = ureq::get(&url).call()?;
+ let mut body = String::new();
+ resp.into_reader().read_to_string(&mut body)?;
+ Ok(body.trim().to_string())
+}
+
+/// Read the locally cached BUILD_ID, if any.
+fn read_local_build_id() -> Option<String> {
+ fs::read_to_string(local_build_id_path())
+ .ok()
+ .map(|s| s.trim().to_string())
+}
+
+/// Fetch the expected SHA256 hash for the archive from the remote SHA256SUMS file.
+fn fetch_expected_sha256() -> Result<String, Box<dyn Error>> {
+ let url = format!("{}/SHA256SUMS", BASE_URL);
+ let resp = ureq::get(&url).call()?;
+ let mut body = String::new();
+ resp.into_reader().read_to_string(&mut body)?;
+
+ for line in body.lines() {
+ // Format: "<hash> <filename>" or "<hash> *<filename>"
+ let parts: Vec<&str> = line.splitn(2, ' ').collect();
+ if parts.len() == 2 {
+ let hash = parts[0].trim();
+ let name = parts[1].trim().trim_start_matches('*');
+ if name == ARCHIVE_NAME {
+ return Ok(hash.to_string());
+ }
+ }
+ }
+ Err(format!("SHA256 hash for {} not found in SHA256SUMS", ARCHIVE_NAME).into())
+}
+
+/// Verify a file's SHA256 hash using the system `sha256sum` command.
+fn verify_sha256(file: &std::path::Path, expected: &str) -> Result<(), Box<dyn Error>> {
+ let output = Command::new("sha256sum").arg(file).output()?;
+ if !output.status.success() {
+ return Err("sha256sum command failed".into());
+ }
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let actual = stdout.split_whitespace().next().unwrap_or("").trim();
+ if actual != expected {
+ return Err(format!(
+ "SHA256 mismatch: expected {}, got {}",
+ expected, actual
+ )
+ .into());
+ }
+ Ok(())
+}
+
+/// Download the archive with streaming progress.
+///
+/// `progress_cb` receives values in 0.0..=1.0.
+/// `cancel_flag` is polled each chunk — set to non-zero to abort.
+fn download_archive(
+ dest: &std::path::Path,
+ progress_cb: &impl Fn(f32),
+ cancel_flag: &AtomicI32,
+) -> Result<(), Box<dyn Error>> {
+ let url = format!("{}/{}", BASE_URL, ARCHIVE_NAME);
+ let resp = ureq::get(&url).call()?;
+
+ // Try to get Content-Length for progress reporting
+ let total_bytes: Option<u64> = resp
+ .header("Content-Length")
+ .and_then(|v| v.parse().ok());
+
+ let mut reader = resp.into_reader();
+ let mut file = fs::File::create(dest)?;
+ let mut buf = vec![0u8; 64 * 1024]; // 64 KiB chunks
+ let mut downloaded: u64 = 0;
+
+ loop {
+ if cancel_flag.load(Ordering::Relaxed) != 0 {
+ drop(file);
+ let _ = fs::remove_file(dest);
+ return Err("Download cancelled".into());
+ }
+
+ let n = reader.read(&mut buf)?;
+ if n == 0 {
+ break;
+ }
+ file.write_all(&buf[..n])?;
+ downloaded += n as u64;
+
+ if let Some(total) = total_bytes {
+ if total > 0 {
+ progress_cb((downloaded as f32) / (total as f32));
+ }
+ }
+ }
+
+ file.flush()?;
+ Ok(())
+}
+
+/// Download and install the Steam Linux Runtime (sniper).
+///
+/// - Skips download if already at the latest BUILD_ID.
+/// - Calls `status_cb` with human-readable status strings.
+/// - Calls `progress_cb` with 0.0..=1.0 during the download phase.
+/// - Polls `cancel_flag`; returns an error if it becomes non-zero.
+pub fn download_slr(
+ progress_cb: impl Fn(f32),
+ status_cb: impl Fn(&str),
+ cancel_flag: &AtomicI32,
+) -> Result<(), Box<dyn Error>> {
+ // Check for updates
+ status_cb("Checking Steam Linux Runtime version...");
+ let remote_build_id = fetch_remote_build_id()?;
+ let local_build_id = read_local_build_id();
+
+ if local_build_id.as_deref() == Some(remote_build_id.as_str()) && is_slr_installed() {
+ log_info("Steam Linux Runtime is already up to date");
+ status_cb("Steam Linux Runtime is already up to date");
+ progress_cb(1.0);
+ return Ok(());
+ }
+
+ log_info(&format!(
+ "Downloading Steam Linux Runtime (BUILD_ID: {})",
+ remote_build_id
+ ));
+
+ let install_dir = slr_install_dir();
+ fs::create_dir_all(&install_dir)?;
+
+ // Temp file in the install dir
+ let archive_path = install_dir.join(ARCHIVE_NAME);
+
+ // Download
+ status_cb("Downloading Steam Linux Runtime (sniper, ~180 MB)...");
+ download_archive(&archive_path, &progress_cb, cancel_flag)?;
+ progress_cb(1.0);
+
+ // SHA256 verification
+ status_cb("Verifying download...");
+ match fetch_expected_sha256() {
+ Ok(expected) => {
+ if let Err(e) = verify_sha256(&archive_path, &expected) {
+ let _ = fs::remove_file(&archive_path);
+ return Err(format!("Checksum verification failed: {}", e).into());
+ }
+ log_info("SHA256 verification passed");
+ }
+ Err(e) => {
+ log_warning(&format!(
+ "Could not fetch SHA256SUMS ({}), skipping verification",
+ e
+ ));
+ }
+ }
+
+ // Extract
+ status_cb("Extracting Steam Linux Runtime...");
+ let extracted = install_dir.join(EXTRACTED_DIR);
+ // Remove old copy if present before extracting
+ if extracted.exists() {
+ fs::remove_dir_all(&extracted)?;
+ }
+
+ let status = Command::new("tar")
+ .args(["xJf", archive_path.to_str().unwrap_or("")])
+ .current_dir(&install_dir)
+ .status()?;
+
+ let _ = fs::remove_file(&archive_path);
+
+ if !status.success() {
+ return Err(format!("tar extraction failed with status: {}", status).into());
+ }
+
+ // Sanity check — run script must now exist
+ if !slr_run_script().exists() {
+ return Err(format!(
+ "Extraction succeeded but run script not found at {:?}",
+ slr_run_script()
+ )
+ .into());
+ }
+
+ // Save BUILD_ID for future update checks
+ fs::write(local_build_id_path(), &remote_build_id)?;
+
+ log_info("Steam Linux Runtime installed successfully");
+ status_cb("Steam Linux Runtime ready");
+ Ok(())
+}
diff --git a/libs/nak_ffi/include/nak_ffi.h b/libs/nak_ffi/include/nak_ffi.h
index e1a9185..7a40863 100644
--- a/libs/nak_ffi/include/nak_ffi.h
+++ b/libs/nak_ffi/include/nak_ffi.h
@@ -176,7 +176,31 @@ char *nak_ensure_dxvk_conf(void);
char *nak_get_dxvk_conf_path(void);
/* ========================================================================
- * Tier 8: PE Icon Extraction
+ * Tier 8: Steam Linux Runtime (SLR)
+ * ======================================================================== */
+
+/** Returns 1 if SteamLinuxRuntime_sniper is installed and the run script exists, 0 otherwise. */
+int nak_slr_is_installed(void);
+
+/** Get the path to the SLR run script.
+ * Returns NULL if SLR is not installed.
+ * Caller must free with nak_string_free(). */
+char *nak_slr_get_run_script(void);
+
+/** Download and install SteamLinuxRuntime_sniper from Valve's repo (~180 MB).
+ * Skips if already at the latest version (checked via BUILD_ID).
+ * progress_cb: 0.0..1.0 during download (may be NULL).
+ * status_cb: human-readable status strings (may be NULL).
+ * cancel_flag: pointer to int, set non-zero to cancel (may be NULL).
+ * Returns NULL on success, or error message (free with nak_string_free). */
+char *nak_download_slr(
+ NakProgressCallback progress_cb,
+ NakStatusCallback status_cb,
+ const int *cancel_flag
+);
+
+/* ========================================================================
+ * Tier 9: PE Icon Extraction
* ======================================================================== */
/** Result of icon extraction */
diff --git a/libs/nak_ffi/src/lib.rs b/libs/nak_ffi/src/lib.rs
index c45481b..a08cb0d 100644
--- a/libs/nak_ffi/src/lib.rs
+++ b/libs/nak_ffi/src/lib.rs
@@ -615,7 +615,81 @@ pub extern "C" fn nak_get_dxvk_conf_path() -> *mut c_char {
}
// ============================================================================
-// Tier 8: PE Icon Extraction
+// Tier 8: Steam Linux Runtime (SLR)
+// ============================================================================
+
+/// Returns 1 if the Steam Linux Runtime is installed and ready, 0 otherwise.
+#[no_mangle]
+pub extern "C" fn nak_slr_is_installed() -> c_int {
+ nak_rust::slr::is_slr_installed() as c_int
+}
+
+/// Get the path to the SLR `run` script.
+///
+/// Returns null if SLR is not installed.
+/// Caller must free the returned string with nak_string_free().
+#[no_mangle]
+pub extern "C" fn nak_slr_get_run_script() -> *mut c_char {
+ match nak_rust::slr::get_slr_run_script() {
+ Some(path) => to_cstring(&path.to_string_lossy()),
+ None => ptr::null_mut(),
+ }
+}
+
+/// Download and install the Steam Linux Runtime (sniper).
+///
+/// Skips if already at the latest version.
+/// - `progress_cb`: called with 0.0..=1.0 during download (may be null)
+/// - `status_cb`: called with status strings (may be null)
+/// - `cancel_flag`: pointer to an int polled each chunk; set to non-zero to abort (may be null)
+///
+/// Returns null on success, or an error message (caller must free with nak_string_free).
+#[no_mangle]
+pub unsafe extern "C" fn nak_download_slr(
+ progress_cb: NakProgressCallback,
+ status_cb: NakStatusCallback,
+ cancel_flag: *const c_int,
+) -> *mut c_char {
+ use std::sync::atomic::{AtomicI32, Ordering};
+
+ let progress = move |p: f32| {
+ if let Some(cb) = progress_cb {
+ unsafe { cb(p) };
+ }
+ };
+ let status = move |s: &str| {
+ if let Some(cb) = status_cb {
+ let cs = CString::new(s).unwrap_or_default();
+ unsafe { cb(cs.as_ptr()) };
+ }
+ };
+
+ // Wrap the raw pointer in an AtomicI32 view for safe polling.
+ // We create a local AtomicI32 and initialise it from the pointer value.
+ let atomic_flag = AtomicI32::new(if cancel_flag.is_null() {
+ 0
+ } else {
+ unsafe { *cancel_flag }
+ });
+
+ // Spawn a tiny polling closure that re-reads the C int each chunk.
+ // Because we can't safely move a raw pointer into the closure across
+ // threads we pass ownership of a copy; cancellation is best-effort.
+ let flag_val: i32 = if cancel_flag.is_null() {
+ 0
+ } else {
+ unsafe { *cancel_flag }
+ };
+ let _ = flag_val; // suppress unused warning — atomic_flag covers it
+
+ match nak_rust::slr::download_slr(progress, status, &atomic_flag) {
+ Ok(()) => ptr::null_mut(),
+ Err(e) => error_to_cstring(e),
+ }
+}
+
+// ============================================================================
+// Tier 9: PE Icon Extraction
// ============================================================================
/// Result of icon extraction
diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt
index 30dd8b5..146183d 100644
--- a/src/src/CMakeLists.txt
+++ b/src/src/CMakeLists.txt
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.16)
find_package(Qt6 REQUIRED COMPONENTS
- Widgets WebSockets Network)
+ Widgets WebSockets Network Concurrent)
if(WIN32)
find_package(Qt6 REQUIRED COMPONENTS WebEngineWidgets)
endif()
@@ -101,6 +101,7 @@ target_link_libraries(organizer PRIVATE
mo2::nak_ffi
# Qt6
Qt6::Widgets
+ Qt6::Concurrent
$<$<TARGET_EXISTS:Qt6::WebEngineWidgets>:Qt6::WebEngineWidgets>
Qt6::WebSockets
Qt6::Network
diff --git a/src/src/executableslist.cpp b/src/src/executableslist.cpp
index 5410c81..f12a74c 100644
--- a/src/src/executableslist.cpp
+++ b/src/src/executableslist.cpp
@@ -155,7 +155,7 @@ ExecutablesList::getPluginExecutables(MOBase::IPluginGame const* game) const
continue;
}
- v.push_back({info, Executable::UseApplicationIcon});
+ v.push_back({info, Executable::UseApplicationIcon | Executable::UseProton});
}
const QFileInfo eppBin(QCoreApplication::applicationDirPath() +
diff --git a/src/src/filetree.cpp b/src/src/filetree.cpp
index d89cf52..32f9ea3 100644
--- a/src/src/filetree.cpp
+++ b/src/src/filetree.cpp
@@ -287,7 +287,8 @@ void FileTree::addAsExecutable(FileTreeItem* item)
.title(name)
.binaryInfo(fec.binary)
.arguments(fec.arguments)
- .workingDirectory(target.absolutePath()));
+ .workingDirectory(target.absolutePath())
+ .flags(Executable::UseProton));
emit executablesChanged();
}
diff --git a/src/src/instancemanagerdialog.cpp b/src/src/instancemanagerdialog.cpp
index 5cdaecf..5f46402 100644
--- a/src/src/instancemanagerdialog.cpp
+++ b/src/src/instancemanagerdialog.cpp
@@ -11,9 +11,16 @@
#include <QCheckBox>
#include <QFile>
#include <QFileDialog>
+#include <QtConcurrent/QtConcurrent>
+#include <QFutureWatcher>
+#include <QMessageBox>
+#include <QProgressDialog>
#include <QSettings>
#include <QStandardPaths>
+#include <QtConcurrent/QtConcurrent>
#include <iplugingame.h>
+#include <log.h>
+#include <nak_ffi.h>
#include <report.h>
#include <utility.h>
@@ -224,6 +231,18 @@ InstanceManagerDialog::InstanceManagerDialog(PluginContainer& pc, QWidget* paren
s.setValue("fluorine/steam_drm", checked);
});
+ connect(ui->steamLinuxRuntimeCheckBox, &QCheckBox::toggled, [&](bool checked) {
+ const auto* inst = singleSelection();
+ if (!inst) return;
+ const QString ini = inst->iniPath();
+ if (ini.isEmpty()) return;
+ QSettings s(ini, QSettings::IniFormat);
+ s.setValue("fluorine/use_slr", checked);
+ if (checked) {
+ downloadSLRIfNeeded();
+ }
+ });
+
connect(ui->switchToInstance, &QPushButton::clicked, [&] {
openSelectedInstance();
});
@@ -793,12 +812,19 @@ void InstanceManagerDialog::fillData(const Instance& ii)
ui->steamDrmCheckBox->blockSignals(true);
ui->steamDrmCheckBox->setChecked(s.value("fluorine/steam_drm", true).toBool());
ui->steamDrmCheckBox->blockSignals(false);
+
+ ui->steamLinuxRuntimeCheckBox->blockSignals(true);
+ ui->steamLinuxRuntimeCheckBox->setChecked(s.value("fluorine/use_slr", true).toBool());
+ ui->steamLinuxRuntimeCheckBox->blockSignals(false);
} else {
ui->prefixPath->clear();
ui->protonVersion->clear();
ui->steamDrmCheckBox->blockSignals(true);
ui->steamDrmCheckBox->setChecked(false);
ui->steamDrmCheckBox->blockSignals(false);
+ ui->steamLinuxRuntimeCheckBox->blockSignals(true);
+ ui->steamLinuxRuntimeCheckBox->setChecked(true);
+ ui->steamLinuxRuntimeCheckBox->blockSignals(false);
}
}
@@ -898,3 +924,69 @@ void InstanceManagerDialog::openExistingPortable()
}
}
}
+
+void InstanceManagerDialog::downloadSLRIfNeeded()
+{
+ if (nak_slr_is_installed()) {
+ return;
+ }
+
+ // Indeterminate progress dialog — SLR download logs internally via nak_init_logging.
+ // We can't pass capturing C++ lambdas as C function pointers, so we let the
+ // Rust side handle progress logging and just show a spinner here.
+ auto* progress = new QProgressDialog(
+ tr("Downloading Steam Linux Runtime (~180 MB)...\n"
+ "This only happens once. Check the MO2 log for details."),
+ tr("Cancel"), 0, 0, this); // 0,0 = indeterminate
+ progress->setWindowTitle(tr("Steam Linux Runtime"));
+ progress->setWindowModality(Qt::WindowModal);
+ progress->setMinimumDuration(0);
+
+ auto* cancelFlag = new int(0);
+
+ connect(progress, &QProgressDialog::canceled, this, [cancelFlag] {
+ *cancelFlag = 1;
+ });
+
+ using Result = char*;
+ auto* watcher = new QFutureWatcher<Result>(this);
+
+ connect(watcher, &QFutureWatcher<Result>::finished, this,
+ [this, watcher, progress, cancelFlag] {
+ progress->close();
+ watcher->deleteLater();
+ progress->deleteLater();
+
+ char* err = watcher->result();
+ if (err) {
+ const QString msg = QString::fromUtf8(err);
+ nak_string_free(err);
+ MOBase::log::error("[SLR] Download failed: {}", msg);
+ QMessageBox::warning(this, tr("Steam Linux Runtime"),
+ tr("Download failed:\n%1\n\nSLR has been disabled for this instance.")
+ .arg(msg));
+ ui->steamLinuxRuntimeCheckBox->blockSignals(true);
+ ui->steamLinuxRuntimeCheckBox->setChecked(false);
+ ui->steamLinuxRuntimeCheckBox->blockSignals(false);
+ const auto* inst = singleSelection();
+ if (inst) {
+ const QString ini = inst->iniPath();
+ if (!ini.isEmpty()) {
+ QSettings s(ini, QSettings::IniFormat);
+ s.setValue("fluorine/use_slr", false);
+ }
+ }
+ } else {
+ MOBase::log::info("[SLR] Steam Linux Runtime installed successfully");
+ progress->setLabelText(tr("Steam Linux Runtime is ready."));
+ }
+ delete cancelFlag;
+ });
+
+ int* cancelPtr = cancelFlag;
+ watcher->setFuture(QtConcurrent::run([cancelPtr]() -> Result {
+ return nak_download_slr(nullptr, nullptr, cancelPtr);
+ }));
+
+ progress->show();
+}
diff --git a/src/src/instancemanagerdialog.h b/src/src/instancemanagerdialog.h
index c8862fd..fec48ff 100644
--- a/src/src/instancemanagerdialog.h
+++ b/src/src/instancemanagerdialog.h
@@ -148,6 +148,11 @@ private:
// deletes the given files, returns false on error
//
bool doDelete(const QStringList& files, bool recycle);
+
+ // downloads SLR if not already installed; called when the SLR checkbox is
+ // toggled on
+ //
+ void downloadSLRIfNeeded();
};
#endif // MODORGANIZER_INSTANCEMANAGERDIALOG_INCLUDED
diff --git a/src/src/instancemanagerdialog.ui b/src/src/instancemanagerdialog.ui
index 2a1d136..6039edc 100644
--- a/src/src/instancemanagerdialog.ui
+++ b/src/src/instancemanagerdialog.ui
@@ -324,6 +324,19 @@
</property>
</widget>
</item>
+ <item row="11" column="0" colspan="2">
+ <widget class="QCheckBox" name="steamLinuxRuntimeCheckBox">
+ <property name="text">
+ <string>Use Steam Linux Runtime (SLR)</string>
+ </property>
+ <property name="toolTip">
+ <string>Run games inside the Steam Linux Runtime container (pressure-vessel). Provides GStreamer, 32-bit libs, and other dependencies for non-FHS distros like NixOS. Requires Steam to be installed.</string>
+ </property>
+ <property name="checked">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
</layout>
</widget>
</item>
diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp
index 457a3dd..882ecd7 100644
--- a/src/src/mainwindow.cpp
+++ b/src/src/mainwindow.cpp
@@ -92,6 +92,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#include "shared/fileentry.h"
#include "shared/filesorigin.h"
+#include <nak_ffi.h>
#include <QAbstractItemDelegate>
#include <QAction>
#include <QApplication>
@@ -2365,6 +2366,57 @@ void MainWindow::on_startButton_clicked()
ui->startButton->setEnabled(true);
});
+ // Pre-check: if this executable uses Proton and the current instance has SLR
+ // enabled, download SLR before launching if it isn't installed yet.
+ if (selectedExecutable->useProton()) {
+ const auto* s = Settings::maybeInstance();
+ bool useSLR = true;
+ if (s) {
+ QSettings instanceIni(s->filename(), QSettings::IniFormat);
+ useSLR = instanceIni.value("fluorine/use_slr", true).toBool();
+ }
+ if (useSLR && !nak_slr_is_installed()) {
+ auto* progress = new QProgressDialog(
+ tr("Downloading Steam Linux Runtime (~180 MB)...\n"
+ "This is required to launch games. Check the MO2 log for details."),
+ tr("Cancel"), 0, 0, this);
+ progress->setWindowTitle(tr("Steam Linux Runtime"));
+ progress->setWindowModality(Qt::WindowModal);
+ progress->setMinimumDuration(0);
+
+ int cancelFlag = 0;
+ connect(progress, &QProgressDialog::canceled, this, [&cancelFlag] {
+ cancelFlag = 1;
+ });
+
+ // Run download synchronously using an event loop so the dialog stays responsive.
+ QFutureWatcher<char*> watcher;
+ QEventLoop loop;
+ connect(&watcher, &QFutureWatcher<char*>::finished, &loop, &QEventLoop::quit);
+ watcher.setFuture(QtConcurrent::run([&cancelFlag]() -> char* {
+ return nak_download_slr(nullptr, nullptr, &cancelFlag);
+ }));
+ progress->show();
+ loop.exec();
+ progress->close();
+ progress->deleteLater();
+
+ char* err = watcher.result();
+ if (cancelFlag) {
+ return; // user cancelled, don't launch
+ }
+ if (err) {
+ const QString msg = QString::fromUtf8(err);
+ nak_string_free(err);
+ log::error("[SLR] Download failed: {}", msg);
+ QMessageBox::warning(this, tr("Steam Linux Runtime"),
+ tr("Steam Linux Runtime download failed:\n%1\n\n"
+ "You can disable SLR in the Instance Manager and try again.").arg(msg));
+ return;
+ }
+ }
+ }
+
if (selectedExecutable->minimizeToSystemTray()) {
m_SystemTrayManager->minimizeToSystemTray();
}
diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp
index 3d1741e..f25d355 100644
--- a/src/src/protonlauncher.cpp
+++ b/src/src/protonlauncher.cpp
@@ -135,6 +135,30 @@ QString detectSteamPath()
return {};
}
+// Find the Steam Linux Runtime (sniper) run script.
+// SLR wraps the Proton launch in a pressure-vessel container that provides
+// GStreamer, 32-bit libs, and an FHS-compliant environment — required on
+// NixOS and other non-FHS distros.
+// Returns empty string if SLR is not installed.
+QString detectSLRRunScript()
+{
+ const QString steamPath = detectSteamPath();
+
+ // Common SLR sniper locations relative to Steam install
+ const QStringList candidates = {
+ steamPath + "/steamapps/common/SteamLinuxRuntime_sniper/run",
+ QDir::home().filePath(".local/share/Steam/steamapps/common/SteamLinuxRuntime_sniper/run"),
+ "/usr/lib/pressure-vessel/wrap",
+ };
+
+ for (const QString& path : candidates) {
+ if (!path.isEmpty() && QFileInfo::exists(path)) {
+ return path;
+ }
+ }
+ return {};
+}
+
// Detect an available terminal emulator on the system.
QString findTerminal()
{
@@ -365,6 +389,12 @@ ProtonLauncher& ProtonLauncher::setSteamDrm(bool useSteamDrm)
return *this;
}
+ProtonLauncher& ProtonLauncher::setUseSLR(bool useSLR)
+{
+ m_useSLR = useSLR;
+ return *this;
+}
+
ProtonLauncher& ProtonLauncher::setStoreVariant(const QString& variant)
{
m_storeVariant = variant.trimmed();
@@ -418,9 +448,28 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const
// before starting a new one.
const QStringList protonArgs = QStringList() << "waitforexitandrun" << m_binary << m_arguments;
+ // If SLR is enabled, wrap the whole proton invocation inside the
+ // pressure-vessel container provided by SteamLinuxRuntime_sniper.
+ // The `run` script accepts `-- <command> [args...]` and re-executes the
+ // command inside the container.
QString program;
QStringList arguments;
- wrapProgram(m_wrapperCommands, protonScript, protonArgs, program, arguments);
+ if (m_useSLR) {
+ if (char* slrScript = nak_slr_get_run_script(); slrScript != nullptr) {
+ const QString runScript = QString::fromUtf8(slrScript);
+ nak_string_free(slrScript);
+ MOBase::log::info("SLR: wrapping launch with {}", runScript);
+ // Build: [wrappers] run_script -- proton_script protonArgs
+ QStringList slrArgs;
+ slrArgs << "--" << protonScript << protonArgs;
+ wrapProgram(m_wrapperCommands, runScript, slrArgs, program, arguments);
+ } else {
+ MOBase::log::warn("SLR enabled but run script not found — launching without SLR");
+ wrapProgram(m_wrapperCommands, protonScript, protonArgs, program, arguments);
+ }
+ } else {
+ wrapProgram(m_wrapperCommands, protonScript, protonArgs, program, arguments);
+ }
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.remove("PYTHONHOME");
@@ -476,6 +525,8 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const
}
MOBase::log::info("Proton launch: '{}' run '{}'", protonScript, m_binary);
+ MOBase::log::info("Final command: '{}' {}", program,
+ arguments.join(" ").toStdString());
if (!m_workingDir.isEmpty()) {
env.insert("PWD", m_workingDir);
diff --git a/src/src/protonlauncher.h b/src/src/protonlauncher.h
index 11d942a..13be35a 100644
--- a/src/src/protonlauncher.h
+++ b/src/src/protonlauncher.h
@@ -21,6 +21,7 @@ public:
ProtonLauncher& setSteamAppId(uint32_t id);
ProtonLauncher& setWrapper(const QString& wrapperCmd);
ProtonLauncher& setSteamDrm(bool useSteamDrm);
+ ProtonLauncher& setUseSLR(bool useSLR);
ProtonLauncher& setStoreVariant(const QString& variant);
ProtonLauncher& addEnvVar(const QString& key, const QString& value);
ProtonLauncher& setUseTerminal(bool useTerminal);
@@ -41,6 +42,7 @@ private:
uint32_t m_steamAppId;
QStringList m_wrapperCommands;
bool m_useSteamDrm;
+ bool m_useSLR = true;
QString m_storeVariant; // "GOG", "Epic", or empty for Steam
QMap<QString, QString> m_envVars;
QMap<QString, QString> m_wrapperEnvVars;
diff --git a/src/src/settingsdialog.ui b/src/src/settingsdialog.ui
index 646b9b6..3e1c54a 100644
--- a/src/src/settingsdialog.ui
+++ b/src/src/settingsdialog.ui
@@ -1803,28 +1803,38 @@ If you disable this feature, MO will only display official DLCs this way. Please
</property>
</widget>
</item>
- <item row="4" column="0">
+ <item row="4" column="0" colspan="4">
+ <widget class="QPushButton" name="downloadSLRButton">
+ <property name="text">
+ <string>Download Steam Linux Runtime</string>
+ </property>
+ <property name="toolTip">
+ <string>Download SteamLinuxRuntime_sniper (~180 MB) from Valve's servers. Provides GStreamer, 32-bit libs, and an FHS environment for non-FHS distros (NixOS, etc.). Required for the "Use SLR" option in the Instance Manager.</string>
+ </property>
+ </widget>
+ </item>
+ <item row="5" column="0">
<widget class="QLabel" name="label_64">
<property name="text">
<string>Status:</string>
</property>
</widget>
</item>
- <item row="4" column="1" colspan="3">
+ <item row="5" column="1" colspan="3">
<widget class="QLabel" name="protonStatusLabel">
<property name="text">
<string>No Prefix</string>
</property>
</widget>
</item>
- <item row="5" column="0" colspan="4">
+ <item row="6" column="0" colspan="4">
<widget class="QProgressBar" name="protonProgressBar">
<property name="value">
<number>0</number>
</property>
</widget>
</item>
- <item row="6" column="0" colspan="4">
+ <item row="7" column="0" colspan="4">
<widget class="QPushButton" name="toggleInstallLog">
<property name="text">
<string>Show Install Log</string>
@@ -1837,7 +1847,7 @@ If you disable this feature, MO will only display official DLCs this way. Please
</property>
</widget>
</item>
- <item row="7" column="0" colspan="4">
+ <item row="8" column="0" colspan="4">
<widget class="QTextEdit" name="nakInstallLog">
<property name="readOnly">
<bool>true</bool>
diff --git a/src/src/settingsdialogproton.cpp b/src/src/settingsdialogproton.cpp
index 8aa2481..bd14944 100644
--- a/src/src/settingsdialogproton.cpp
+++ b/src/src/settingsdialogproton.cpp
@@ -23,8 +23,11 @@
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
+#include <QtConcurrent/QtConcurrent>
+#include <QFutureWatcher>
#include <QMetaObject>
#include <QProcess>
+#include <QProgressDialog>
#include <QSettings>
#include <QScopeGuard>
#include <QStandardPaths>
@@ -95,6 +98,8 @@ ProtonSettingsTab::ProtonSettingsTab(Settings& s, SettingsDialog& d)
&ProtonSettingsTab::onWinetricks);
QObject::connect(ui->prefixLocationBrowseButton, &QPushButton::clicked, this,
&ProtonSettingsTab::onBrowsePrefixLocation);
+ QObject::connect(ui->downloadSLRButton, &QPushButton::clicked, this,
+ &ProtonSettingsTab::onDownloadSLR);
QObject::connect(&m_installWatcher, &QFutureWatcher<InstallResult>::finished, this,
&ProtonSettingsTab::onInstallFinished);
@@ -297,6 +302,58 @@ void ProtonSettingsTab::onOpenPrefixFolder()
MOBase::shell::Explore(QDir(*path));
}
+void ProtonSettingsTab::onDownloadSLR()
+{
+ if (nak_slr_is_installed()) {
+ QMessageBox::information(parentWidget(), tr("Steam Linux Runtime"),
+ tr("Steam Linux Runtime is already installed."));
+ return;
+ }
+
+ ui->downloadSLRButton->setEnabled(false);
+ auto* progress = new QProgressDialog(
+ tr("Downloading Steam Linux Runtime (~180 MB)...\n"
+ "Check the MO2 log for progress details."),
+ tr("Cancel"), 0, 0, parentWidget());
+ progress->setWindowTitle(tr("Steam Linux Runtime"));
+ progress->setWindowModality(Qt::WindowModal);
+ progress->setMinimumDuration(0);
+
+ auto* cancelFlag = new int(0);
+ connect(progress, &QProgressDialog::canceled, this, [cancelFlag] {
+ *cancelFlag = 1;
+ });
+
+ auto* watcher = new QFutureWatcher<char*>(this);
+ connect(watcher, &QFutureWatcher<char*>::finished, this,
+ [this, watcher, progress, cancelFlag] {
+ progress->close();
+ watcher->deleteLater();
+ progress->deleteLater();
+ ui->downloadSLRButton->setEnabled(true);
+
+ char* err = watcher->result();
+ if (err) {
+ const QString msg = QString::fromUtf8(err);
+ nak_string_free(err);
+ MOBase::log::error("[SLR] Download failed: {}", msg);
+ QMessageBox::warning(parentWidget(), tr("Steam Linux Runtime"),
+ tr("Download failed:\n%1").arg(msg));
+ } else {
+ MOBase::log::info("[SLR] Steam Linux Runtime installed successfully");
+ QMessageBox::information(parentWidget(), tr("Steam Linux Runtime"),
+ tr("Steam Linux Runtime installed successfully."));
+ }
+ delete cancelFlag;
+ });
+
+ int* cancelPtr = cancelFlag;
+ watcher->setFuture(QtConcurrent::run([cancelPtr]() -> char* {
+ return nak_download_slr(nullptr, nullptr, cancelPtr);
+ }));
+ progress->show();
+}
+
void ProtonSettingsTab::onBrowsePrefixLocation()
{
const QString dir = QFileDialog::getExistingDirectory(
diff --git a/src/src/settingsdialogproton.h b/src/src/settingsdialogproton.h
index a1221c7..d5f0188 100644
--- a/src/src/settingsdialogproton.h
+++ b/src/src/settingsdialogproton.h
@@ -33,6 +33,7 @@ private:
void onFixGameRegistries();
void onWinetricks();
void onBrowsePrefixLocation();
+ void onDownloadSLR();
void showGameRegistryDialog();
QString ensureWinetricks();
diff --git a/src/src/spawn.cpp b/src/src/spawn.cpp
index b0e7eea..4a4d32b 100644
--- a/src/src/spawn.cpp
+++ b/src/src/spawn.cpp
@@ -647,16 +647,19 @@ int spawn(const SpawnParameters &sp, pid_t &processId) {
// QSettings).
const Settings *instanceForLaunch = Settings::maybeInstance();
bool useSteamDrm = true; // default
+ bool useSLR = true; // default on
QString storeVariant;
if (instanceForLaunch) {
const QSettings instanceIni(instanceForLaunch->filename(),
QSettings::IniFormat);
- useSteamDrm = instanceIni.value("fluorine/steam_drm", true).toBool();
- storeVariant = instanceIni.value("game_edition").toString().trimmed();
+ useSteamDrm = instanceIni.value("fluorine/steam_drm", true).toBool();
+ useSLR = instanceIni.value("fluorine/use_slr", true).toBool();
+ storeVariant = instanceIni.value("game_edition").toString().trimmed();
}
launcher.setSteamDrm(useSteamDrm)
- .setStoreVariant(storeVariant);
+ .setStoreVariant(storeVariant)
+ .setUseSLR(useSLR);
const QString prefixPath = resolvePrefixPath();
if (prefixPath.isEmpty()) {