feat: pass profile settings to launch scripts
Add launcher profile settings for username and language, then thread those values into the Windows script launch path. The game setup, game start, and server start scripts now share the same argument shape: - game path: local - game id - language: en or de - player name Expose a local can_host_server flag in the games payload by checking for server_start.cmd in an installed game's root directory. The detail modal uses that flag to show Start Server only for installed games with the script, and the new start_server command invokes server_start.cmd with the same sanitized settings used by game_setup.cmd and game_start.cmd. Test Plan: - just fmt - just test - just frontend-test - just build - just clippy - git diff --check Refs: design/README.md
This commit is contained in:
@@ -63,10 +63,17 @@ struct UiActiveOperation {
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
struct GamesListPayload {
|
||||
games: Vec<Game>,
|
||||
games: Vec<LauncherGame>,
|
||||
active_operations: Vec<UiActiveOperation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
struct LauncherGame {
|
||||
#[serde(flatten)]
|
||||
game: Game,
|
||||
can_host_server: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
struct UnpackLogEntry {
|
||||
archive: String,
|
||||
@@ -104,6 +111,17 @@ async fn get_unpack_logs(
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
const GAME_SETUP_SCRIPT: &str = "game_setup.cmd";
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
const GAME_START_SCRIPT: &str = "game_start.cmd";
|
||||
const SERVER_START_SCRIPT: &str = "server_start.cmd";
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
const DEFAULT_LANGUAGE: &str = "en";
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
const DEFAULT_USERNAME: &str = "Commander";
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
const MAX_USERNAME_CHARS: usize = 24;
|
||||
|
||||
#[tauri::command]
|
||||
async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> {
|
||||
@@ -381,6 +399,57 @@ fn is_single_component_game_id(id: &str) -> bool {
|
||||
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
struct LaunchSettings {
|
||||
language: String,
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn launch_settings(language: &str, username: &str) -> LaunchSettings {
|
||||
LaunchSettings {
|
||||
language: sanitize_language(language),
|
||||
username: sanitize_username(username),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn sanitize_language(language: &str) -> String {
|
||||
match language.trim().to_ascii_lowercase().as_str() {
|
||||
"de" => "de".to_string(),
|
||||
"en" => "en".to_string(),
|
||||
_ => DEFAULT_LANGUAGE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn sanitize_username(username: &str) -> String {
|
||||
let cleaned = username
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() && *c != '"' && *c != '%')
|
||||
.take(MAX_USERNAME_CHARS)
|
||||
.collect::<String>();
|
||||
|
||||
if cleaned.is_empty() {
|
||||
DEFAULT_USERNAME.to_string()
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String {
|
||||
format!(
|
||||
r#"/d /s /c ""{}" "local" "{}" "{}" "{}"""#,
|
||||
script_path.display(),
|
||||
id,
|
||||
settings.language,
|
||||
settings.username,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> {
|
||||
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
|
||||
@@ -445,8 +514,16 @@ fn run_as_admin(file: &str, params: &str, dir: &str) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn run_game_windows(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<()> {
|
||||
if !is_single_component_game_id(&id) {
|
||||
log::warn!("Ignoring run request for invalid game id: {id}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let settings = launch_settings(&language, &username);
|
||||
let games_folder_lock = state.inner().games_folder.clone();
|
||||
let games_folder = {
|
||||
let guard = games_folder_lock.read().await;
|
||||
@@ -461,8 +538,8 @@ async fn run_game_windows(
|
||||
|
||||
let game_path = games_folder.join(id.clone());
|
||||
|
||||
let game_setup_bin = game_path.join("game_setup.cmd");
|
||||
let game_start_bin = game_path.join("game_start.cmd");
|
||||
let game_setup_bin = game_path.join(GAME_SETUP_SCRIPT);
|
||||
let game_start_bin = game_path.join(GAME_START_SCRIPT);
|
||||
|
||||
let first_start_done_file = game_path.join("local").join(FIRST_START_DONE_FILE);
|
||||
if !first_start_done_file.exists() && game_setup_bin.exists() {
|
||||
@@ -476,16 +553,12 @@ async fn run_game_windows(
|
||||
|
||||
let result = run_as_admin(
|
||||
"cmd.exe",
|
||||
&format!(
|
||||
r#"/c "{} local {} de playername""#,
|
||||
game_setup_bin.display(),
|
||||
&id
|
||||
),
|
||||
&script_params(&game_setup_bin, &id, &settings),
|
||||
&game_path.display().to_string(),
|
||||
);
|
||||
|
||||
if !result {
|
||||
log::error!("failed to run game_setup.cmd");
|
||||
log::error!("failed to run {GAME_SETUP_SCRIPT}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -500,16 +573,12 @@ async fn run_game_windows(
|
||||
if game_start_bin.exists() {
|
||||
let result = run_as_admin(
|
||||
"cmd.exe",
|
||||
&format!(
|
||||
r#"/c "{} local {} de playername""#,
|
||||
game_start_bin.display(),
|
||||
&id
|
||||
),
|
||||
&script_params(&game_start_bin, &id, &settings),
|
||||
&game_path.display().to_string(),
|
||||
);
|
||||
|
||||
if !result {
|
||||
log::error!("failed to run game_start.cmd");
|
||||
log::error!("failed to run {GAME_START_SCRIPT}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,21 +586,97 @@ async fn run_game_windows(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn run_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> {
|
||||
async fn run_game(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
run_game_windows(id, state).await?;
|
||||
run_game_windows(id, language, username, state).await?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = state;
|
||||
let _ = (state, language, username);
|
||||
log::error!("run_game not implemented for this platform: id={id}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn start_server_windows(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
if !is_single_component_game_id(&id) {
|
||||
log::warn!("Ignoring server start request for invalid game id: {id}");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let settings = launch_settings(&language, &username);
|
||||
let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone());
|
||||
if !games_folder.exists() {
|
||||
log::error!("games_folder {} does not exist", games_folder.display());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let game_path = games_folder.join(id.clone());
|
||||
if !local_install_is_present(&game_path) {
|
||||
log::warn!(
|
||||
"local install is missing for {}; skipping {SERVER_START_SCRIPT}",
|
||||
game_path.display()
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let server_start_bin = game_path.join(SERVER_START_SCRIPT);
|
||||
if !server_start_bin.is_file() {
|
||||
log::warn!(
|
||||
"server start script is missing for {}: {}",
|
||||
id,
|
||||
server_start_bin.display()
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let result = run_as_admin(
|
||||
"cmd.exe",
|
||||
&script_params(&server_start_bin, &id, &settings),
|
||||
&game_path.display().to_string(),
|
||||
);
|
||||
|
||||
if !result {
|
||||
log::error!("failed to run {SERVER_START_SCRIPT}");
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_server(
|
||||
id: String,
|
||||
language: String,
|
||||
username: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
start_server_windows(id, language, username, state).await
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (state, language, username);
|
||||
log::error!("start_server not implemented for this platform: id={id}");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn local_install_is_present(game_path: &Path) -> bool {
|
||||
game_path.join("local").is_dir()
|
||||
@@ -592,6 +737,7 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
||||
|
||||
let games_db_lock = state.games.clone();
|
||||
let game_db = games_db_lock.read().await;
|
||||
let games_folder = state.games_folder.read().await.clone();
|
||||
|
||||
if game_db.games.is_empty() {
|
||||
log::debug!("Game database empty; skipping emit");
|
||||
@@ -602,7 +748,11 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
||||
.all_games()
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<Game>>();
|
||||
.map(|game| LauncherGame {
|
||||
can_host_server: game_can_host_server(&games_folder, &game),
|
||||
game,
|
||||
})
|
||||
.collect::<Vec<LauncherGame>>();
|
||||
|
||||
drop(game_db);
|
||||
|
||||
@@ -623,6 +773,17 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn game_can_host_server(games_folder: &str, game: &Game) -> bool {
|
||||
if !game.installed || games_folder.is_empty() || !is_single_component_game_id(&game.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
PathBuf::from(games_folder)
|
||||
.join(&game.id)
|
||||
.join(SERVER_START_SCRIPT)
|
||||
.is_file()
|
||||
}
|
||||
|
||||
fn ui_active_operations_from_map(
|
||||
active_operations: &HashMap<String, UiOperationKind>,
|
||||
) -> Vec<UiActiveOperation> {
|
||||
@@ -1440,6 +1601,68 @@ mod tests {
|
||||
assert!(!is_single_component_game_id("/game"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_settings_sanitize_script_arguments() {
|
||||
assert_eq!(
|
||||
launch_settings("DE", " Alice \"Ace\"%PATH%\n "),
|
||||
LaunchSettings {
|
||||
language: "de".to_string(),
|
||||
username: "Alice AcePATH".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
launch_settings("fr", ""),
|
||||
LaunchSettings {
|
||||
language: DEFAULT_LANGUAGE.to_string(),
|
||||
username: DEFAULT_USERNAME.to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_params_use_common_argument_shape() {
|
||||
let params = script_params(
|
||||
Path::new("C:/Games/My Game")
|
||||
.join(GAME_START_SCRIPT)
|
||||
.as_path(),
|
||||
"my-game",
|
||||
&LaunchSettings {
|
||||
language: "en".to_string(),
|
||||
username: "Alice".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
params,
|
||||
r#"/d /s /c ""C:/Games/My Game/game_start.cmd" "local" "my-game" "en" "Alice"""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_host_capability_requires_installed_game_with_script() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"lanspread-server-test-{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_nanos()
|
||||
));
|
||||
let game_root = root.join("game");
|
||||
std::fs::create_dir_all(&game_root).expect("game root should be created");
|
||||
std::fs::write(game_root.join(SERVER_START_SCRIPT), b"").expect("script should be written");
|
||||
|
||||
let mut game = game_fixture("game", "Game");
|
||||
assert!(!game_can_host_server(
|
||||
root.to_string_lossy().as_ref(),
|
||||
&game
|
||||
));
|
||||
|
||||
game.installed = true;
|
||||
assert!(game_can_host_server(root.to_string_lossy().as_ref(), &game));
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_local_snapshot_replaces_local_state_without_overwriting_catalog_metadata() {
|
||||
let mut alpha = game_fixture("alpha", "Catalog Alpha");
|
||||
@@ -1506,6 +1729,7 @@ pub fn run() {
|
||||
request_games,
|
||||
install_game,
|
||||
run_game,
|
||||
start_server,
|
||||
update_game_directory,
|
||||
update_game,
|
||||
uninstall_game,
|
||||
|
||||
@@ -21,6 +21,15 @@ export const Icon = {
|
||||
<path d="M4 2.5v11l10-5.5z" />
|
||||
</svg>
|
||||
),
|
||||
server: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
|
||||
<rect x="2" y="3" width="12" height="4.5" rx="1" />
|
||||
<rect x="2" y="8.5" width="12" height="4.5" rx="1" />
|
||||
<circle cx="4.6" cy="5.25" r=".55" fill="currentColor" stroke="none" />
|
||||
<circle cx="4.6" cy="10.75" r=".55" fill="currentColor" stroke="none" />
|
||||
<path d="M7 5.25h4.5M7 10.75h4.5" />
|
||||
</svg>
|
||||
),
|
||||
install: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.7} {...baseStroke} {...p}>
|
||||
<path d="M8 2v8" />
|
||||
|
||||
@@ -16,6 +16,7 @@ interface Props {
|
||||
onUninstall: (game: Game) => void;
|
||||
onRemoveDownload: (game: Game) => void;
|
||||
onCancelDownload: (game: Game) => void;
|
||||
onStartServer: (game: Game) => void;
|
||||
onViewFiles: (game: Game) => void;
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ export const GameDetailModal = ({
|
||||
onUninstall,
|
||||
onRemoveDownload,
|
||||
onCancelDownload,
|
||||
onStartServer,
|
||||
onViewFiles,
|
||||
}: Props) => {
|
||||
const tags = tagsFromGame(game);
|
||||
@@ -116,6 +118,16 @@ export const GameDetailModal = ({
|
||||
onClick={() => onPrimary(game)}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
{game.installed && game.can_host_server === true && (
|
||||
<button
|
||||
type="button"
|
||||
className="act-btn act-lg act-server"
|
||||
onClick={() => onStartServer(game)}
|
||||
>
|
||||
<Icon.server />
|
||||
<span className="act-label">Start Server</span>
|
||||
</button>
|
||||
)}
|
||||
{game.installed && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
ASPECT_OPTIONS,
|
||||
BG_OPTIONS,
|
||||
DENSITY_OPTIONS,
|
||||
UISettings,
|
||||
LANGUAGE_OPTIONS,
|
||||
type UISettings,
|
||||
} from '../../hooks/useSettings';
|
||||
|
||||
interface Props {
|
||||
@@ -33,6 +34,26 @@ const Row = ({ label, hint, children }: RowProps) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
interface TextInputProps {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
maxLength: number;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInputProps) => (
|
||||
<div className="settings-text">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
spellCheck={false}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
|
||||
<Modal onClose={onClose} className="settings-modal">
|
||||
<div className="settings-head">
|
||||
@@ -48,6 +69,25 @@ export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
|
||||
</div>
|
||||
|
||||
<div className="settings-body">
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">Profile</div>
|
||||
<Row label="Username" hint="Shown to other players on the LAN">
|
||||
<SettingsTextInput
|
||||
value={settings.username}
|
||||
placeholder="Enter a username"
|
||||
maxLength={24}
|
||||
onChange={(v) => onChange('username', v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Language" hint="Interface language">
|
||||
<SegmentedRadio
|
||||
value={settings.language}
|
||||
options={LANGUAGE_OPTIONS}
|
||||
onChange={(v) => onChange('language', v)}
|
||||
/>
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">Appearance</div>
|
||||
<Row label="Accent color" hint="Used for primary actions and highlights">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import { UseGamesResult } from './useGames';
|
||||
import { type UseGamesResult } from './useGames';
|
||||
import { type UISettings } from './useSettings';
|
||||
|
||||
export interface GameActions {
|
||||
play: (id: string) => Promise<void>;
|
||||
startServer: (id: string) => Promise<void>;
|
||||
install: (id: string) => Promise<void>;
|
||||
update: (id: string) => Promise<void>;
|
||||
uninstall: (id: string) => Promise<void>;
|
||||
@@ -19,14 +21,33 @@ export interface GameActions {
|
||||
* are marked as "checking peers" until the backend emits an authoritative
|
||||
* operation snapshot; cancellation waits for the backend to clear that snapshot.
|
||||
*/
|
||||
export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||
export const useGameActions = (
|
||||
games: UseGamesResult,
|
||||
settings: Pick<UISettings, 'language' | 'username'>,
|
||||
): GameActions => {
|
||||
const play = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('run_game', { id });
|
||||
await invoke('run_game', {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('run_game failed:', err);
|
||||
}
|
||||
}, []);
|
||||
}, [settings.language, settings.username]);
|
||||
|
||||
const startServer = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('start_server', {
|
||||
id,
|
||||
language: settings.language,
|
||||
username: settings.username,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('start_server failed:', err);
|
||||
}
|
||||
}, [settings.language, settings.username]);
|
||||
|
||||
const install = useCallback(async (id: string) => {
|
||||
try {
|
||||
@@ -83,5 +104,5 @@ export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { play, install, update, uninstall, removeDownload, cancelDownload, viewFiles };
|
||||
return { play, startServer, install, update, uninstall, removeDownload, cancelDownload, viewFiles };
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { load } from '@tauri-apps/plugin-store';
|
||||
|
||||
import { GameFilter, GameSort } from '../lib/types';
|
||||
import { type GameFilter, type GameSort, type LauncherLanguage } from '../lib/types';
|
||||
import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store';
|
||||
|
||||
export type Density = 'compact' | 'normal' | 'large';
|
||||
@@ -15,10 +15,14 @@ export interface UISettings {
|
||||
aspect: CoverAspect;
|
||||
sort: GameSort;
|
||||
filter: GameFilter;
|
||||
username: string;
|
||||
language: LauncherLanguage;
|
||||
}
|
||||
|
||||
type StoredGameSort = GameSort | 'size';
|
||||
type StoredUISettings = Partial<Omit<UISettings, 'sort'> & { sort: StoredGameSort }>;
|
||||
const DEFAULT_USERNAME = 'Commander';
|
||||
const MAX_USERNAME_LENGTH = 24;
|
||||
|
||||
export const ACCENT_OPTIONS = [
|
||||
{ value: '#3b82f6', label: 'Blue' },
|
||||
@@ -47,6 +51,18 @@ export const ASPECT_OPTIONS: ReadonlyArray<{ value: CoverAspect; label: string }
|
||||
{ value: 'banner', label: 'Banner' },
|
||||
];
|
||||
|
||||
export const LANGUAGE_OPTIONS: ReadonlyArray<{ value: LauncherLanguage; label: string }> = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
];
|
||||
|
||||
const defaultLanguage = (): LauncherLanguage => {
|
||||
if (typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('de')) {
|
||||
return 'de';
|
||||
}
|
||||
return 'en';
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: UISettings = {
|
||||
accent: '#3b82f6',
|
||||
bg: 'gradient',
|
||||
@@ -54,6 +70,8 @@ export const DEFAULT_SETTINGS: UISettings = {
|
||||
aspect: 'square',
|
||||
sort: 'status',
|
||||
filter: 'local',
|
||||
username: DEFAULT_USERNAME,
|
||||
language: defaultLanguage(),
|
||||
};
|
||||
|
||||
const sanitize = (raw: StoredUISettings | undefined): UISettings => ({
|
||||
@@ -63,12 +81,23 @@ const sanitize = (raw: StoredUISettings | undefined): UISettings => ({
|
||||
aspect: raw?.aspect ?? DEFAULT_SETTINGS.aspect,
|
||||
sort: sanitizeSort(raw?.sort),
|
||||
filter: raw?.filter ?? DEFAULT_SETTINGS.filter,
|
||||
username: sanitizeUsername(raw?.username),
|
||||
language: sanitizeLanguage(raw?.language),
|
||||
});
|
||||
|
||||
const sanitizeSort = (sort: StoredGameSort | undefined): GameSort => (
|
||||
sort === 'size' ? 'sizeDesc' : sort ?? DEFAULT_SETTINGS.sort
|
||||
);
|
||||
|
||||
const sanitizeLanguage = (language: LauncherLanguage | undefined): LauncherLanguage => (
|
||||
language === 'de' || language === 'en' ? language : DEFAULT_SETTINGS.language
|
||||
);
|
||||
|
||||
const sanitizeUsername = (username: string | undefined): string => {
|
||||
const trimmed = username?.trim() ?? '';
|
||||
return trimmed ? Array.from(trimmed).slice(0, MAX_USERNAME_LENGTH).join('') : DEFAULT_USERNAME;
|
||||
};
|
||||
|
||||
export interface UseSettings {
|
||||
settings: UISettings;
|
||||
set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface Game {
|
||||
status_level?: StatusLevel;
|
||||
download_progress?: DownloadProgress;
|
||||
peer_count: number;
|
||||
can_host_server?: boolean;
|
||||
}
|
||||
|
||||
export interface ActiveOperation {
|
||||
@@ -78,3 +79,6 @@ export type GameSort = 'az' | 'sizeDesc' | 'sizeAsc' | 'status';
|
||||
|
||||
/** Visual state of a card. Derived from backend operation status and local flags. */
|
||||
export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'busy';
|
||||
|
||||
/** Two-character language code passed through to game scripts. */
|
||||
export type LauncherLanguage = 'en' | 'de';
|
||||
|
||||
@@ -807,6 +807,20 @@
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: var(--bd-3);
|
||||
}
|
||||
.act-server {
|
||||
color: var(--t-1);
|
||||
background: color-mix(in srgb, var(--accent) 14%, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.act-server:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent) 22%, rgba(255, 255, 255, 0.04));
|
||||
border-color: color-mix(in srgb, var(--accent) 75%, transparent);
|
||||
filter: none;
|
||||
}
|
||||
.act-server svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
.act-busy {
|
||||
color: var(--t-1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
@@ -1475,6 +1489,43 @@
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Settings: text input */
|
||||
.settings-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 220px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--bd-1);
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.settings-text:focus-within {
|
||||
background: var(--bg-2);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
.settings-text input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--t-1);
|
||||
font: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.settings-text input::placeholder {
|
||||
color: var(--t-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Settings: color swatches */
|
||||
.swatch-row {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -47,7 +47,7 @@ export const MainWindow = () => {
|
||||
const { settings, set: setSetting } = useSettings();
|
||||
const { gameDir, setGameDir, rescan } = useGameDirectory();
|
||||
const games = useGames(rescan);
|
||||
const actions = useGameActions(games);
|
||||
const actions = useGameActions(games, settings);
|
||||
const thumbnails = useThumbnails();
|
||||
|
||||
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||
@@ -172,6 +172,7 @@ export const MainWindow = () => {
|
||||
onUninstall={handleUninstall}
|
||||
onRemoveDownload={handleRemoveDownload}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
onStartServer={(g) => actions.startServer(g.id)}
|
||||
onViewFiles={(g) => actions.viewFiles(g.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user