Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
427 changes: 427 additions & 0 deletions app/src/main/app/shell/LaunchOptionsScreen.kt

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions app/src/main/app/shell/LibraryGameLaunchScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.RocketLaunch
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.Save
Expand Down Expand Up @@ -140,6 +141,8 @@ internal fun LibraryGameLaunchScreen(
showVerifyFiles: Boolean = true,
showCheckForUpdate: Boolean = true,
showWorkshop: Boolean = true,
showLaunchOptions: Boolean = false,
onLaunchOptions: () -> Unit = {},
playEnabled: Boolean = true,
playDisabledLabel: String? = null,
onBack: () -> Unit,
Expand Down Expand Up @@ -278,11 +281,13 @@ internal fun LibraryGameLaunchScreen(
showCheckForUpdate = showCheckForUpdate,
showWorkshop = showWorkshop,
showAchievements = onAchievements != null,
showLaunchOptions = showLaunchOptions,
areSteamActionsEnabled = areSteamActionsEnabled,
onVerifyFiles = onVerifyFiles,
onCheckForUpdate = onCheckForUpdate,
onWorkshop = onWorkshop,
onAchievements = { onAchievements?.invoke() },
onLaunchOptions = onLaunchOptions,
)
}

Expand Down Expand Up @@ -821,11 +826,13 @@ private fun SourceTag(
showCheckForUpdate: Boolean = true,
showWorkshop: Boolean = true,
showAchievements: Boolean = false,
showLaunchOptions: Boolean = false,
areSteamActionsEnabled: Boolean = true,
onVerifyFiles: () -> Unit = {},
onCheckForUpdate: () -> Unit = {},
onWorkshop: () -> Unit = {},
onAchievements: () -> Unit = {},
onLaunchOptions: () -> Unit = {},
) {
var menuOpen by remember { mutableStateOf(false) }
var anchorHeightPx by remember { mutableStateOf(0) }
Expand Down Expand Up @@ -902,6 +909,13 @@ private fun SourceTag(
label = stringResource(R.string.steam_achievements_title),
) { menuOpen = false; onAchievements() }
}
if (showLaunchOptions) {
LaunchSourceMenuItem(
icon = Icons.Outlined.RocketLaunch,
label = stringResource(R.string.store_game_launch_options),
enabled = areSteamActionsEnabled,
) { menuOpen = false; onLaunchOptions() }
}
}
}
}
Expand Down
18 changes: 17 additions & 1 deletion app/src/main/app/shell/StoreGameDetailScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.Extension
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.RocketLaunch
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material.icons.outlined.SystemUpdate
Expand Down Expand Up @@ -155,6 +156,8 @@ internal fun StoreGameDetailScreen(
showWorkshop: Boolean = false,
showVerifyFiles: Boolean = false,
areSteamActionsEnabled: Boolean = true,
showLaunchOptions: Boolean = false,
onLaunchOptions: () -> Unit = {},
dlcs: List<StoreDlcItem> = emptyList(),
selectedDlcIds: Set<Int> = emptySet(),
isDlcSelectionEnabled: Boolean = true,
Expand Down Expand Up @@ -202,7 +205,9 @@ internal fun StoreGameDetailScreen(
val showUpdateCta = updateCheckAvailable && isUpdateAvailable
val verifyFilesAvailable = showVerifyFiles && isInstalled
val workshopAvailable = showWorkshop && isInstalled
val sourceMenuEnabled = updateCheckAvailable || verifyFilesAvailable || workshopAvailable
val launchOptionsAvailable = showLaunchOptions && isInstalled
val sourceMenuEnabled =
updateCheckAvailable || verifyFilesAvailable || workshopAvailable || launchOptionsAvailable
val showDlcCard = dlcs.isNotEmpty()
val showActionColumn =
showDownloadCta || showUpdateCta ||
Expand Down Expand Up @@ -313,6 +318,7 @@ internal fun StoreGameDetailScreen(
showCheckForUpdate = updateCheckAvailable,
showVerifyFiles = verifyFilesAvailable,
showWorkshop = workshopAvailable,
showLaunchOptions = launchOptionsAvailable,
isCheckingForUpdate = isCheckingForUpdate,
areSteamActionsEnabled = areSteamActionsEnabled,
isUpdateCheckEnabled =
Expand All @@ -323,6 +329,7 @@ internal fun StoreGameDetailScreen(
onVerifyFiles = onVerifyFiles,
onCheckForUpdate = onCheckForUpdate,
onWorkshop = onWorkshop,
onLaunchOptions = onLaunchOptions,
)
}

Expand Down Expand Up @@ -824,12 +831,14 @@ private fun StoreSourceTag(
showCheckForUpdate: Boolean = false,
showVerifyFiles: Boolean = false,
showWorkshop: Boolean = false,
showLaunchOptions: Boolean = false,
isCheckingForUpdate: Boolean = false,
areSteamActionsEnabled: Boolean = true,
isUpdateCheckEnabled: Boolean = true,
onVerifyFiles: () -> Unit = {},
onCheckForUpdate: () -> Unit = {},
onWorkshop: () -> Unit = {},
onLaunchOptions: () -> Unit = {},
) {
var anchorHeightPx by remember { mutableIntStateOf(0) }
Box {
Expand Down Expand Up @@ -913,6 +922,13 @@ private fun StoreSourceTag(
enabled = areSteamActionsEnabled,
) { onMenuOpenChange(false); onWorkshop() }
}
if (showLaunchOptions) {
StoreSourceMenuItem(
icon = Icons.Outlined.RocketLaunch,
label = stringResource(R.string.store_game_launch_options),
enabled = areSteamActionsEnabled,
) { onMenuOpenChange(false); onLaunchOptions() }
}
}
}
}
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/app/shell/UnifiedActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5656,6 +5656,9 @@ class UnifiedActivity :
val isEpic = app.id >= 2000000000
val isGog = gogGame != null
val epicId = if (isEpic) app.id - 2000000000 else 0
val isSteamLibraryGame = !isCustom && !isEpic && !isGog

val launchOptionsState = rememberSteamLaunchOptionsState(app.id, enabled = isSteamLibraryGame)

val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState(
initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(),
Expand Down Expand Up @@ -6387,6 +6390,8 @@ class UnifiedActivity :
(!isEpic || epicGame?.isInstalled == true) &&
(!isGog || gogGame?.isInstalled == true),
showWorkshop = !isEpic && !isGog,
showLaunchOptions = launchOptionsState.options.size >= 2,
onLaunchOptions = launchOptionsState::show,
areSteamActionsEnabled =
when {
isEpic -> !hasBlockingEpicDownloadForLibrary
Expand Down Expand Up @@ -6885,6 +6890,8 @@ class UnifiedActivity :
onDismissRequest = { showWorkshopDialog = false },
)
}

SteamLaunchOptionsDialogHost(app.id, app.name, launchOptionsState)
}
}
}
Expand Down Expand Up @@ -9936,6 +9943,7 @@ class UnifiedActivity :
var isCheckingForUpdate by remember(app.id) { mutableStateOf(false) }
var isUpdateCheckCoolingDown by remember(app.id) { mutableStateOf(false) }
var showWorkshopDialog by remember(app.id) { mutableStateOf(false) }
val launchOptionsState = rememberSteamLaunchOptionsState(app.id, enabled = installed == true)
var updateInfo by remember(app.id) { mutableStateOf<SteamService.SteamUpdateInfo?>(null) }
var updateStatusText by remember(app.id) { mutableStateOf<String?>(null) }
val downloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState(
Expand Down Expand Up @@ -10122,6 +10130,8 @@ class UnifiedActivity :
showWorkshop = isReallyInstalled,
showVerifyFiles = isReallyInstalled,
areSteamActionsEnabled = !hasBlockingSteamDownload,
showLaunchOptions = launchOptionsState.options.size >= 2,
onLaunchOptions = launchOptionsState::show,
dlcs = dlcItems,
selectedDlcIds = selectedDlcIds.toSet(),
isDlcSelectionEnabled = steamDownloadRecord == null,
Expand Down Expand Up @@ -10261,6 +10271,8 @@ class UnifiedActivity :
onDismissRequest = { showWorkshopDialog = false },
)
}

SteamLaunchOptionsDialogHost(app.id, app.name, launchOptionsState)
}

@Composable
Expand Down
Binary file modified app/src/main/assets/wnsteam/bionic/steam.exe
Binary file not shown.
86 changes: 80 additions & 6 deletions app/src/main/cpp/wn-steam-launcher/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -304,18 +304,90 @@ static int count_game_processes(const char* exeName) {
return count;
}

static bool append_char(char* buf, size_t cap, size_t* n, char c) {
if (*n + 1 >= cap) return false;
buf[(*n)++] = c;
return true;
}

// Appends one argv element re-quoted per CommandLineToArgvW rules (backslash runs
// before an emitted quote must be doubled); false when the buffer would overflow.
static bool append_quoted_arg(char* buf, size_t cap, size_t* n, const char* a) {
const bool quote = (a[0] == '\0') || strpbrk(a, " \t\"") != NULL;
if (!quote) {
for (const char* p = a; *p; ++p)
if (!append_char(buf, cap, n, *p)) return false;
return true;
}
if (!append_char(buf, cap, n, '"')) return false;
const char* p = a;
while (*p) {
size_t backslashes = 0;
while (*p == '\\') { backslashes++; p++; }
if (*p == '\0') {
// Run ends the arg: double it so the closing quote stays a delimiter.
for (size_t k = 0; k < backslashes * 2; k++)
if (!append_char(buf, cap, n, '\\')) return false;
break;
}
if (*p == '"') {
for (size_t k = 0; k < backslashes * 2 + 1; k++)
if (!append_char(buf, cap, n, '\\')) return false;
} else {
for (size_t k = 0; k < backslashes; k++)
if (!append_char(buf, cap, n, '\\')) return false;
}
if (!append_char(buf, cap, n, *p)) return false;
p++;
}
return append_char(buf, cap, n, '"');
}

// Re-joins argv[2..] for forwarding onto the game's command line; on overflow
// whole trailing args are dropped (never a half token) with a logged warning.
static const char* build_extra_args(int argc, char** argv) {
static char buf[4096];
size_t n = 0;
buf[0] = '\0';
for (int i = 2; i < argc; i++) {
size_t before = n;
bool ok = (n == 0) || append_char(buf, sizeof(buf), &n, ' ');
ok = ok && append_quoted_arg(buf, sizeof(buf), &n, argv[i]);
if (!ok) {
n = before;
log_line("[wn-launcher] extra args exceed %zu bytes - dropped %d trailing arg(s)",
sizeof(buf), argc - i);
break;
}
}
buf[n] = '\0';
return buf;
}

// Direct launch when LaunchApp dispatches cleanly but never spawns the game (no
// real Steam UI/reaper under Wine to consume the request). Safe vs AlreadyRunning
// because the clean-shutdown arm reaps the CM session on exit. Logs the "game
// process started pid=" marker WnLauncherStatusTailer treats as launch-complete.
static bool create_process_game(const char* gameExe, const char* exeName) {
static bool create_process_game(const char* gameExe, const char* exeName,
const char* extraArgs) {
char cwd[MAX_PATH];
snprintf(cwd, sizeof(cwd), "%s", gameExe);
char* slash = strrchr(cwd, '\\');
if (slash) *slash = '\0'; else cwd[0] = '\0';

char cmd[MAX_PATH + 8];
snprintf(cmd, sizeof(cmd), "\"%s\"", gameExe);
char cmd[MAX_PATH + 4096 + 16];
int written;
if (extraArgs && extraArgs[0]) {
written = snprintf(cmd, sizeof(cmd), "\"%s\" %s", gameExe, extraArgs);
} else {
written = snprintf(cmd, sizeof(cmd), "\"%s\"", gameExe);
}
if (written < 0 || written >= (int) sizeof(cmd)) {
log_line("[wn-launcher] CreateProcess cmd truncated (%d bytes)", written);
}
// Args content stays out of the log — execArgs can carry +password style credentials.
log_line("[wn-launcher] CreateProcess exe=\"%s\" argsLen=%zu",
gameExe, (extraArgs && extraArgs[0]) ? strlen(extraArgs) : (size_t) 0);

STARTUPINFOA si;
memset(&si, 0, sizeof(si));
Expand Down Expand Up @@ -758,13 +830,15 @@ int main(int argc, char** argv) {
const char* token = getenv("WN_STEAM_TOKEN");
uint64_t steamId = env_u64("WN_STEAM_STEAMID");
const char* gameExe = (argc > 1) ? argv[1] : NULL;
const char* extraArgs = build_extra_args(argc, argv);
uint32_t appId = appIdStr ? (uint32_t) strtoul(appIdStr, NULL, 10) : 0;

log_line("[wn-launcher] env appId=%u steamId=%llu user=%s exe=%s",
log_line("[wn-launcher] env appId=%u steamId=%llu user=%s exe=%s argsLen=%zu",
appId,
(unsigned long long) steamId,
user ? user : "(null)",
gameExe ? gameExe : "(null)");
gameExe ? gameExe : "(null)",
strlen(extraArgs));
if (token && *token) {
size_t tokenLen = strlen(token);
log_line("[wn-launcher] token len=%zu prefix=%.*s suffix=%.*s",
Expand Down Expand Up @@ -1393,7 +1467,7 @@ int main(int argc, char** argv) {
"— falling back to CreateProcess (%s)",
exeName, launchFailureReason);
}
launchedViaFallback = create_process_game(gameExe, exeName);
launchedViaFallback = create_process_game(gameExe, exeName, extraArgs);
}

if (launchedViaApp || launchedViaFallback) {
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/feature/library/GameSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,9 @@ class GameSettingsStateHolder {
val name = mutableStateOf("")
val launchExePath = mutableStateOf("")
val launchExeDisplayPath = mutableStateOf("")

// Read-only: args the selected Steam launch option appends at launch (display only).
val launchOptionArgs = mutableStateOf("")
val containerEntries = mutableStateOf<List<String>>(emptyList())
val selectedContainer = mutableIntStateOf(0)
val screenSizeEntries = mutableStateOf<List<String>>(emptyList())
Expand Down Expand Up @@ -3654,6 +3657,15 @@ private fun AdvancedSection(
)
}

if (state.launchOptionArgs.value.isNotBlank()) {
Spacer(Modifier.height(SettingTightGap))
Text(
stringResource(R.string.shortcut_launch_option_args, state.launchOptionArgs.value),
color = TextDim,
fontSize = 10.sp
)
}

Spacer(Modifier.height(SettingItemGap))

SettingCheckbox(
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import com.winlator.cmod.feature.settings.GraphicsDriverConfigUtils
import com.winlator.cmod.feature.settings.WineD3DConfigUtils
import com.winlator.cmod.feature.setup.SetupWizardActivity
import com.winlator.cmod.feature.stores.steam.events.AndroidEvent
import com.winlator.cmod.feature.stores.steam.service.SteamService
import com.winlator.cmod.runtime.compat.box64.Box64Preset
import com.winlator.cmod.runtime.compat.box64.Box64PresetManager
import com.winlator.cmod.runtime.container.Container
Expand Down Expand Up @@ -358,6 +359,7 @@ class ShortcutSettingsComposeDialog private constructor(
state.name.value = shortcut.name
state.launchExePath.value = resolveInitialLaunchExePath()
state.launchExeDisplayPath.value = resolveLaunchExeDisplayPath(state.launchExePath.value)
state.launchOptionArgs.value = shortcut.getExtra("launch_exe_args")
syncLibraryArtworkState()

val inputType = Integer.parseInt(
Expand Down Expand Up @@ -1192,6 +1194,14 @@ class ShortcutSettingsComposeDialog private constructor(
// Launch EXE path
val launchExePath = normalizeLaunchExeForShortcut(state.launchExePath.value)
if (launchExePath.isNotEmpty()) {
val storedExePath = SteamService.normalizeRelativeExe(shortcut.getExtra("launch_exe_path"))
// A manual exe CHANGE turns the Steam launch option off ("" marker, not key removal).
// Normalized compare: appinfo '\'/case variants of the same exe must not false-trigger.
if (!SteamService.normalizeRelativeExe(launchExePath).equals(storedExePath, ignoreCase = true)) {
shortcut.putExtra("launch_exe_args", null)
shortcut.putExtra("launch_option_exe", "")
shortcut.putExtra("launch_option_args", null)
}
shortcut.putExtra("launch_exe_path", launchExePath)
val gameSource = shortcut.getExtra("game_source", "")
if (gameSource == "CUSTOM") {
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/feature/stores/steam/data/LaunchInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ data class LaunchInfo(
val workingDir: String,
val description: String,
val type: String,
// Default keeps already-cached appinfo JSON (without this key) decodable.
val arguments: String = "",
@Serializable(with = OsEnumSetSerializer::class)
val configOS: java.util.EnumSet<OS>,
val configArch: OSArch,
Expand Down
Loading