Compare commits

...

7 Commits

Author SHA1 Message Date
DecDuck 032cbda498 Fix server lint 2026-06-21 15:12:16 +10:00
DecDuck 6290826c4d Implement tree kill for Windows 2026-06-21 15:11:25 +10:00
DecDuck 1430d2ef3d Fix layouting 2026-06-21 13:40:44 +10:00
DecDuck e3a1824e7d Fix torrential inclusion in image 2026-06-21 13:23:05 +10:00
DecDuck 27502bf31f Regenerate lcofkiel 2026-06-21 13:06:56 +10:00
DecDuck 564a4aa6f0 Add process handler selector, pin Prisma 2026-06-21 13:03:15 +10:00
DecDuck a028db7288 Fix Windows and Linux launch 2026-06-21 11:56:45 +10:00
19 changed files with 1763 additions and 1753 deletions
+9 -1
View File
@@ -63,6 +63,14 @@ FROM base AS run-system
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1 ENV NUXT_TELEMETRY_DISABLED=1
# The base stage's `COPY . .` puts the whole repo into the runtime WORKDIR (/app),
# but at runtime only the artifacts copied explicitly below are needed. Drop the
# inherited `torrential` source dir: the service resolves the binary by scanning
# the cwd for `torrential`, and a directory there is spawned as ./torrential and
# fails with EACCES. With it gone, resolution falls through to the `torrential`
# binary installed on PATH (/usr/bin/torrential) below.
RUN rm -rf /app/torrential
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1 # RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1
## runtime deps: ## runtime deps:
## - libarchive13: torrential now links libarchive dynamically (glibc build) ## - libarchive13: torrential now links libarchive dynamically (glibc build)
@@ -77,7 +85,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
openssl \ openssl \
ca-certificates \ ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN pnpm install prisma@7.3.0 --global RUN pnpm install prisma@7.7.0 --global
# init prisma to download all required files # init prisma to download all required files
RUN pnpm prisma init RUN pnpm prisma init
@@ -0,0 +1,141 @@
<template>
<Listbox
as="div"
v-model="model.overrideHandler"
class="mt-6"
v-if="handlers.length > 1"
>
<ListboxLabel class="block text-sm/6 font-medium text-white"
>Launch method</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="grid w-full cursor-default grid-cols-1 rounded-md bg-white/5 py-1.5 pr-2 pl-3 text-left text-white outline-1 -outline-offset-1 outline-white/10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-500 sm:text-sm/6"
>
<span
v-if="currentHandler"
class="col-start-1 row-start-1 truncate pr-6"
>{{ currentHandler.name }}</span
>
<span
v-else
class="col-start-1 row-start-1 truncate pr-6 italic text-zinc-400"
>Automatic</span
>
<ChevronUpDownIcon
class="col-start-1 row-start-1 size-5 self-center justify-self-end text-zinc-400 sm:size-4"
aria-hidden="true"
/>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class=""
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-800 py-1 text-base outline-1 -outline-offset-1 outline-white/10 sm:text-sm"
>
<ListboxOption
as="template"
:value="undefined"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-500 text-white outline-hidden' : 'text-white',
'relative cursor-default py-2 pr-9 pl-3 select-none',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate italic',
]"
>Automatic</span
>
<span class="block truncate text-xs text-zinc-400"
>Pick the best method for this game.</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-400',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
<ListboxOption
as="template"
v-for="handler in handlers"
:key="handler.id"
:value="handler.id"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-500 text-white outline-hidden' : 'text-white',
'relative cursor-default py-2 pr-9 pl-3 select-none',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ handler.name }}</span
>
<span class="block truncate text-xs text-zinc-400">{{
handler.description
}}</span>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-400',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
<p class="mt-2 text-sm text-zinc-400">
Override how this game is launched.
</p>
</Listbox>
</template>
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
import { CheckIcon } from "@heroicons/vue/20/solid";
import type { GameVersion } from "~/types";
type ProcessHandlerOption = { id: string; name: string; description: string };
const model = defineModel<GameVersion["userConfiguration"]>({ required: true });
const props = defineProps<{ gameId: string }>();
const handlers = await invoke<ProcessHandlerOption[]>("get_process_handlers", {
id: props.gameId,
});
const currentHandler = computed(() =>
handlers.find((v) => v.id == model.value.overrideHandler),
);
</script>
@@ -23,16 +23,19 @@
</p> </p>
<ProtonSelector v-model="model" v-if="$props.protonEnabled" /> <ProtonSelector v-model="model" v-if="$props.protonEnabled" />
<HandlerSelector v-model="model" :game-id="$props.gameId" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { GameVersion } from "~/types"; import type { GameVersion } from "~/types";
import ProtonSelector from "./ProtonSelector.vue"; import ProtonSelector from "./ProtonSelector.vue";
import HandlerSelector from "./HandlerSelector.vue";
const model = defineModel<GameVersion["userConfiguration"]>({ required: true }); const model = defineModel<GameVersion["userConfiguration"]>({ required: true });
const props = defineProps<{ const props = defineProps<{
protonEnabled: boolean; protonEnabled: boolean;
gameId: string;
}>(); }>();
</script> </script>
+3 -2
View File
@@ -1,7 +1,7 @@
<template> <template>
<ModalTemplate size-class="max-w-4xl" v-model="open"> <ModalTemplate size-class="max-w-4xl" v-model="open">
<template #default> <template #default>
<div class="flex flex-row gap-x-4 h-96"> <div class="flex flex-row gap-x-4 min-h-96">
<nav class="flex flex-1 flex-col" aria-label="Sidebar"> <nav class="flex flex-1 flex-col" aria-label="Sidebar">
<ul role="list" class="-mx-2 space-y-1"> <ul role="list" class="-mx-2 space-y-1">
<li v-for="(tab, tabIdx) in tabs" :key="tab.name"> <li v-for="(tab, tabIdx) in tabs" :key="tab.name">
@@ -29,11 +29,12 @@
</li> </li>
</ul> </ul>
</nav> </nav>
<div class="border-l-2 border-zinc-800 w-full grow pl-4 overflow-y-scroll"> <div class="border-l-2 border-zinc-800 w-full grow pl-4">
<component <component
v-model="configuration" v-model="configuration"
:is="tabs[currentTabIndex]?.page" :is="tabs[currentTabIndex]?.page"
:proton-enabled="protonEnabled" :proton-enabled="protonEnabled"
:game-id="props.gameId"
/> />
</div> </div>
</div> </div>
+1 -1
View File
@@ -22,7 +22,7 @@
"koa": "^2.16.1", "koa": "^2.16.1",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"micromark": "^4.0.1", "micromark": "^4.0.1",
"nuxt": "^3.21.6", "nuxt": "^4.4.8",
"scss": "^0.2.4", "scss": "^0.2.4",
"vue-router": "latest", "vue-router": "latest",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
+794 -1525
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -53,6 +53,7 @@ export type GameVersion = {
userConfiguration: { userConfiguration: {
launchTemplate: string; launchTemplate: string;
overrideProtonPath: string; overrideProtonPath: string;
overrideHandler: string | undefined;
enableUpdates: boolean enableUpdates: boolean
}; };
setups: Array<{ platform: string }>; setups: Array<{ platform: string }>;
+1
View File
@@ -1392,6 +1392,7 @@ dependencies = [
"http-serde 2.1.1", "http-serde 2.1.1",
"humansize", "humansize",
"known-folders", "known-folders",
"libloading",
"log", "log",
"log4rs", "log4rs",
"md5 0.7.0", "md5 0.7.0",
+3
View File
@@ -136,6 +136,9 @@ tauri-build = { version = "*", features = [] }
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies] [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] } tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
[target."cfg(target_os = \"linux\")".dependencies]
libloading = "0.7"
[profile.release] [profile.release]
lto = true lto = true
panic = "abort" panic = "abort"
+3
View File
@@ -79,6 +79,7 @@ pub mod data {
UserConfiguration { UserConfiguration {
launch_template: "{}".to_owned(), launch_template: "{}".to_owned(),
override_proton_path: None, override_proton_path: None,
override_handler: None,
enable_updates: false, enable_updates: false,
} }
} }
@@ -88,6 +89,8 @@ pub mod data {
pub struct UserConfiguration { pub struct UserConfiguration {
pub launch_template: String, pub launch_template: String,
pub override_proton_path: Option<String>, pub override_proton_path: Option<String>,
#[serde(default)]
pub override_handler: Option<String>,
pub enable_updates: bool, pub enable_updates: bool,
} }
@@ -1,11 +1,15 @@
use std::{fs::create_dir_all, path::PathBuf, process::Command}; use std::{
fs::create_dir_all,
path::{Path, PathBuf},
process::Command,
};
use client::compat::{COMPAT_INFO, UMU_LAUNCHER_EXECUTABLE}; use client::compat::{COMPAT_INFO, UMU_LAUNCHER_EXECUTABLE};
use database::{ use database::{
Database, DownloadableMetadata, GameVersion, db::DATA_ROOT_DIR, platform::Platform, Database, DownloadableMetadata, GameVersion, db::DATA_ROOT_DIR, platform::Platform,
}; };
use crate::{error::ProcessError, process_manager::ProcessHandler}; use crate::{error::ProcessError, parser::ParsedCommand, process_manager::ProcessHandler};
pub struct MacLauncher; pub struct MacLauncher;
impl ProcessHandler for MacLauncher { impl ProcessHandler for MacLauncher {
@@ -25,11 +29,89 @@ impl ProcessHandler for MacLauncher {
} }
fn modify_command(&self, _command: &mut Command) {} fn modify_command(&self, _command: &mut Command) {}
fn id(&self) -> &'static str {
"macos"
}
fn name(&self) -> &'static str {
"Direct"
}
fn description(&self) -> &'static str {
"Launches the game directly on macOS."
}
} }
#[allow(dead_code)] #[allow(dead_code)]
const CREATE_NO_WINDOW: u32 = 0x08000000; const CREATE_NO_WINDOW: u32 = 0x08000000;
#[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
fn apply_no_window(command: &mut Command) {
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
command.creation_flags(CREATE_NO_WINDOW);
}
}
enum WindowsLaunchStrategy {
Direct,
Cmd,
Powershell,
}
// Wrap a launch command for Windows; with no strategy, detect it from the file extension.
fn windows_launch_command(
launch_command: String,
current_dir: &str,
strategy: Option<WindowsLaunchStrategy>,
) -> Result<String, ProcessError> {
let mut parsed = ParsedCommand::parse(launch_command)?;
let strategy = strategy.unwrap_or_else(|| {
let extension = Path::new(&parsed.command)
.extension()
.and_then(|ext| ext.to_str())
.map(str::to_ascii_lowercase);
match extension.as_deref() {
Some("ps1") => WindowsLaunchStrategy::Powershell,
Some("exe") | Some("com") => WindowsLaunchStrategy::Direct,
_ => WindowsLaunchStrategy::Cmd,
}
});
match strategy {
// PowerShell scripts
WindowsLaunchStrategy::Powershell => {
parsed.make_absolute(PathBuf::from(current_dir));
let script = std::mem::replace(&mut parsed.command, "powershell".to_owned());
let mut args = vec![
"-NoProfile".to_owned(),
"-ExecutionPolicy".to_owned(),
"Bypass".to_owned(),
"-File".to_owned(),
script,
];
args.append(&mut parsed.args);
parsed.args = args;
}
// Direct executables
WindowsLaunchStrategy::Direct => {
parsed.make_absolute(PathBuf::from(current_dir));
}
// cmd.exe, for batch files, builtins, PATHEXT resolution, %VAR% expansion, etc.
WindowsLaunchStrategy::Cmd => {
let command = std::mem::replace(&mut parsed.command, "cmd".to_owned());
let mut args = vec!["/C".to_owned(), command];
args.append(&mut parsed.args);
parsed.args = args;
}
}
Ok(parsed.reconstruct())
}
pub struct WindowsLauncher; pub struct WindowsLauncher;
impl ProcessHandler for WindowsLauncher { impl ProcessHandler for WindowsLauncher {
fn create_launch_process( fn create_launch_process(
@@ -37,22 +119,169 @@ impl ProcessHandler for WindowsLauncher {
_meta: &DownloadableMetadata, _meta: &DownloadableMetadata,
launch_command: String, launch_command: String,
_game_version: &GameVersion, _game_version: &GameVersion,
_current_dir: &str, current_dir: &str,
_database: &Database, _database: &Database,
) -> Result<String, ProcessError> { ) -> Result<String, ProcessError> {
Ok(format!("pwsh \"cmd /C \"{}\"\"", launch_command)) windows_launch_command(launch_command, current_dir, None)
} }
fn valid_for_platform(&self, _db: &Database, _target: &Platform) -> bool { fn valid_for_platform(&self, _db: &Database, _target: &Platform) -> bool {
true true
} }
#[allow(unused_variables)]
fn modify_command(&self, command: &mut Command) { fn modify_command(&self, command: &mut Command) {
#[cfg(target_os = "windows")] apply_no_window(command);
use std::os::windows::process::CommandExt; }
#[cfg(target_os = "windows")]
command.creation_flags(CREATE_NO_WINDOW); fn id(&self) -> &'static str {
"windows-auto"
}
fn name(&self) -> &'static str {
"Automatic"
}
fn description(&self) -> &'static str {
"Detects the file type and launches it directly, or through cmd or PowerShell."
}
}
pub struct WindowsDirectLauncher;
impl ProcessHandler for WindowsDirectLauncher {
fn create_launch_process(
&self,
_meta: &DownloadableMetadata,
launch_command: String,
_game_version: &GameVersion,
current_dir: &str,
_database: &Database,
) -> Result<String, ProcessError> {
windows_launch_command(launch_command, current_dir, Some(WindowsLaunchStrategy::Direct))
}
fn valid_for_platform(&self, _db: &Database, _target: &Platform) -> bool {
true
}
fn modify_command(&self, command: &mut Command) {
apply_no_window(command);
}
fn id(&self) -> &'static str {
"windows-direct"
}
fn name(&self) -> &'static str {
"Direct executable"
}
fn description(&self) -> &'static str {
"Runs the executable directly, without a shell."
}
}
pub struct WindowsCmdLauncher;
impl ProcessHandler for WindowsCmdLauncher {
fn create_launch_process(
&self,
_meta: &DownloadableMetadata,
launch_command: String,
_game_version: &GameVersion,
current_dir: &str,
_database: &Database,
) -> Result<String, ProcessError> {
windows_launch_command(launch_command, current_dir, Some(WindowsLaunchStrategy::Cmd))
}
fn valid_for_platform(&self, _db: &Database, _target: &Platform) -> bool {
true
}
fn modify_command(&self, command: &mut Command) {
apply_no_window(command);
}
fn id(&self) -> &'static str {
"windows-cmd"
}
fn name(&self) -> &'static str {
"Command Prompt (cmd)"
}
fn description(&self) -> &'static str {
"Launches through cmd.exe. Supports batch files, builtins and %VAR% expansion."
}
}
pub struct WindowsPowershellLauncher;
impl ProcessHandler for WindowsPowershellLauncher {
fn create_launch_process(
&self,
_meta: &DownloadableMetadata,
launch_command: String,
_game_version: &GameVersion,
current_dir: &str,
_database: &Database,
) -> Result<String, ProcessError> {
windows_launch_command(
launch_command,
current_dir,
Some(WindowsLaunchStrategy::Powershell),
)
}
fn valid_for_platform(&self, _db: &Database, _target: &Platform) -> bool {
true
}
fn modify_command(&self, command: &mut Command) {
apply_no_window(command);
}
fn id(&self) -> &'static str {
"windows-powershell"
}
fn name(&self) -> &'static str {
"PowerShell"
}
fn description(&self) -> &'static str {
"Runs the command as a PowerShell script (-File)."
}
}
pub struct LinuxNativeLauncher;
impl ProcessHandler for LinuxNativeLauncher {
fn create_launch_process(
&self,
_meta: &DownloadableMetadata,
launch_command: String,
_game_version: &GameVersion,
_current_dir: &str,
_database: &Database,
) -> Result<String, ProcessError> {
// Run native Linux games directly, no umu-run wrapper
Ok(launch_command)
}
fn valid_for_platform(&self, _db: &Database, _target: &Platform) -> bool {
true
}
fn modify_command(&self, _command: &mut Command) {}
fn id(&self) -> &'static str {
"linux-native"
}
fn name(&self) -> &'static str {
"Native (direct)"
}
fn description(&self) -> &'static str {
"Runs the native Linux game directly on the host."
} }
} }
@@ -101,6 +330,18 @@ impl ProcessHandler for UMUNativeLauncher {
} }
fn modify_command(&self, _command: &mut Command) {} fn modify_command(&self, _command: &mut Command) {}
fn id(&self) -> &'static str {
"linux-umu"
}
fn name(&self) -> &'static str {
"Steam Linux Runtime (umu-run)"
}
fn description(&self) -> &'static str {
"Runs the native Linux game inside umu-run's Steam Linux Runtime."
}
} }
pub struct UMUCompatLauncher; pub struct UMUCompatLauncher;
@@ -168,6 +409,18 @@ impl ProcessHandler for UMUCompatLauncher {
} }
fn modify_command(&self, _command: &mut Command) {} fn modify_command(&self, _command: &mut Command) {}
fn id(&self) -> &'static str {
"proton-umu"
}
fn name(&self) -> &'static str {
"Proton (umu-run)"
}
fn description(&self) -> &'static str {
"Runs the Windows game through Proton using umu-run."
}
} }
pub struct AsahiMuvmLauncher; pub struct AsahiMuvmLauncher;
@@ -228,4 +481,16 @@ impl ProcessHandler for AsahiMuvmLauncher {
} }
fn modify_command(&self, _command: &mut Command) {} fn modify_command(&self, _command: &mut Command) {}
fn id(&self) -> &'static str {
"proton-muvm"
}
fn name(&self) -> &'static str {
"Proton + muvm (Asahi)"
}
fn description(&self) -> &'static str {
"Runs through Proton inside a muvm microVM, for Apple Silicon / Asahi Linux."
}
} }
@@ -28,7 +28,8 @@ use crate::{
format::DropFormatArgs, format::DropFormatArgs,
parser::{LaunchParameters, ParsedCommand}, parser::{LaunchParameters, ParsedCommand},
process_handlers::{ process_handlers::{
AsahiMuvmLauncher, MacLauncher, UMUCompatLauncher, UMUNativeLauncher, WindowsLauncher, AsahiMuvmLauncher, LinuxNativeLauncher, MacLauncher, UMUCompatLauncher, UMUNativeLauncher,
WindowsCmdLauncher, WindowsDirectLauncher, WindowsLauncher, WindowsPowershellLauncher,
}, },
}; };
@@ -54,6 +55,13 @@ pub struct LaunchOption {
name: String, name: String,
} }
#[derive(Serialize)]
pub struct ProcessHandlerOption {
id: String,
name: String,
description: String,
}
impl ProcessManager<'_> { impl ProcessManager<'_> {
pub fn new(app_handle: AppHandle) -> Self { pub fn new(app_handle: AppHandle) -> Self {
let log_output_dir = DATA_ROOT_DIR.join("logs"); let log_output_dir = DATA_ROOT_DIR.join("logs");
@@ -76,6 +84,22 @@ impl ProcessManager<'_> {
(Platform::Windows, Platform::Windows), (Platform::Windows, Platform::Windows),
&WindowsLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static), &WindowsLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
), ),
(
(Platform::Windows, Platform::Windows),
&WindowsDirectLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
(
(Platform::Windows, Platform::Windows),
&WindowsCmdLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
(
(Platform::Windows, Platform::Windows),
&WindowsPowershellLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
(
(Platform::Linux, Platform::Linux),
&LinuxNativeLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
( (
(Platform::Linux, Platform::Linux), (Platform::Linux, Platform::Linux),
&UMUNativeLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static), &UMUNativeLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
@@ -101,7 +125,7 @@ impl ProcessManager<'_> {
match self.processes.get_mut(&game_id) { match self.processes.get_mut(&game_id) {
Some(process) => { Some(process) => {
process.manually_killed = true; process.manually_killed = true;
process.handle.kill()?; kill_process_tree(&process.handle)?;
let exit_status = process.handle.wait()?; let exit_status = process.handle.wait()?;
info!("exit status: {:?}", exit_status); info!("exit status: {:?}", exit_status);
Ok(()) Ok(())
@@ -188,7 +212,21 @@ impl ProcessManager<'_> {
&self, &self,
db_lock: &Database, db_lock: &Database,
target_platform: &Platform, target_platform: &Platform,
override_id: Option<&str>,
) -> Result<&(dyn ProcessHandler + Send + Sync), ProcessError> { ) -> Result<&(dyn ProcessHandler + Send + Sync), ProcessError> {
// An explicit override wins, as long as it's valid for the current platform.
if let Some(override_id) = override_id
&& let Some(handler) = self.game_launchers.iter().find(|e| {
let (e_current, e_target) = e.0;
e_current == self.current_platform
&& e_target == *target_platform
&& e.1.id() == override_id
&& e.1.valid_for_platform(db_lock, target_platform)
})
{
return Ok(handler.1);
}
Ok(self Ok(self
.game_launchers .game_launchers
.iter() .iter()
@@ -204,10 +242,44 @@ impl ProcessManager<'_> {
pub fn valid_platform(&self, platform: &Platform) -> bool { pub fn valid_platform(&self, platform: &Platform) -> bool {
let db_lock = borrow_db_checked(); let db_lock = borrow_db_checked();
let process_handler = self.fetch_process_handler(&db_lock, platform); let process_handler = self.fetch_process_handler(&db_lock, platform, None);
process_handler.is_ok() process_handler.is_ok()
} }
pub fn get_process_handlers(
&self,
game_id: String,
) -> Result<Vec<ProcessHandlerOption>, ProcessError> {
let db_lock = borrow_db_checked();
let meta = db_lock
.applications
.installed_game_version
.get(&game_id)
.cloned()
.ok_or(ProcessError::NotInstalled)?;
let target_platform = meta.target_platform;
let handlers = self
.game_launchers
.iter()
.filter(|e| {
let (e_current, e_target) = e.0;
e_current == self.current_platform
&& e_target == target_platform
&& e.1.valid_for_platform(&db_lock, &target_platform)
})
.map(|e| ProcessHandlerOption {
id: e.1.id().to_string(),
name: e.1.name().to_string(),
description: e.1.description().to_string(),
})
.collect();
Ok(handlers)
}
pub fn get_launch_options(game_id: String) -> Result<Vec<LaunchOption>, ProcessError> { pub fn get_launch_options(game_id: String) -> Result<Vec<LaunchOption>, ProcessError> {
let db_lock = borrow_db_checked(); let db_lock = borrow_db_checked();
@@ -310,7 +382,12 @@ impl ProcessManager<'_> {
let target_platform = meta.target_platform; let target_platform = meta.target_platform;
let process_handler = self.fetch_process_handler(&db_lock, &target_platform)?; let process_handler = self.fetch_process_handler(
&db_lock,
&target_platform,
game_version.user_configuration.override_handler.as_deref(),
)?;
debug!("using process handler {:?}", process_handler.id());
let (target_command, emulator) = match game_status { let (target_command, emulator) = match game_status {
GameDownloadStatus::Installed { GameDownloadStatus::Installed {
@@ -516,6 +593,30 @@ impl ProcessManager<'_> {
} }
} }
fn kill_process_tree(handle: &SharedChild) -> io::Result<()> {
#[cfg(target_os = "windows")]
{
// handle.kill() only terminates the launched process (often a cmd or
// powershell wrapper), orphaning the actual game. taskkill /T kills the
// whole process tree.
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let pid = handle.id().to_string();
let killed = Command::new("taskkill")
.args(["/F", "/T", "/PID", pid.as_str()])
.creation_flags(CREATE_NO_WINDOW)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false);
if killed {
return Ok(());
}
}
handle.kill()
}
pub trait ProcessHandler: Send + 'static { pub trait ProcessHandler: Send + 'static {
fn create_launch_process( fn create_launch_process(
&self, &self,
@@ -529,4 +630,8 @@ pub trait ProcessHandler: Send + 'static {
fn valid_for_platform(&self, db: &Database, target: &Platform) -> bool; fn valid_for_platform(&self, db: &Database, target: &Platform) -> bool;
fn modify_command(&self, command: &mut Command); fn modify_command(&self, command: &mut Command);
fn id(&self) -> &'static str;
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
} }
+51 -11
View File
@@ -8,8 +8,17 @@
#![deny(clippy::all)] #![deny(clippy::all)]
use std::{ use std::{
env, fs::File, io::Write, panic::PanicHookInfo, path::Path, str::FromStr, env,
sync::nonpoison::Mutex, time::SystemTime, fs::File,
io::Write,
panic::PanicHookInfo,
path::Path,
str::FromStr,
sync::{
atomic::{AtomicBool, Ordering},
nonpoison::Mutex,
},
time::SystemTime,
}; };
use ::client::{ use ::client::{
@@ -260,6 +269,7 @@ pub fn run() {
get_autostart_enabled, get_autostart_enabled,
open_process_logs, open_process_logs,
get_launch_options, get_launch_options,
get_process_handlers,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
::process::compat::fetch_proton_paths, ::process::compat::fetch_proton_paths,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -359,8 +369,17 @@ pub fn run() {
) )
.expect("Failed to generate menu"); .expect("Failed to generate menu");
if env::var("NO_TRAY_ICON").is_ok_and(|value| value.to_lowercase() == "true") {
TRAY_DISABLED.store(true, Ordering::Relaxed);
} else if !tray_icon_supported() {
warn!(
"appindicator library not available at runtime, disabling system tray icon"
);
TRAY_DISABLED.store(true, Ordering::Relaxed);
}
run_on_tray(|| { run_on_tray(|| {
TrayIconBuilder::new() let tray = TrayIconBuilder::new()
.icon( .icon(
app.default_window_icon() app.default_window_icon()
.expect("Failed to get default window icon") .expect("Failed to get default window icon")
@@ -383,8 +402,12 @@ pub fn run() {
warn!("menu event not handled: {:?}", event.id); warn!("menu event not handled: {:?}", event.id);
} }
}) })
.build(app) .build(app);
.expect("error while setting up tray menu");
if let Err(e) = tray {
warn!("failed to set up system tray icon, disabling tray: {e}");
TRAY_DISABLED.store(true, Ordering::Relaxed);
}
}); });
{ {
@@ -445,13 +468,30 @@ pub fn run() {
}); });
} }
fn run_on_tray<T: FnOnce()>(f: T) { static TRAY_DISABLED: AtomicBool = AtomicBool::new(false);
if match std::env::var("NO_TRAY_ICON") {
Ok(s) => s.to_lowercase() != "true", #[cfg(target_os = "linux")]
Err(_) => true, fn tray_icon_supported() -> bool {
} { [
(f)(); "libayatana-appindicator3.so.1",
"libappindicator3.so.1",
"libayatana-appindicator3.so",
"libappindicator3.so",
]
.iter()
.any(|name| unsafe { libloading::Library::new(name) }.is_ok())
} }
#[cfg(not(target_os = "linux"))]
fn tray_icon_supported() -> bool {
true
}
fn run_on_tray<T: FnOnce()>(f: T) {
if TRAY_DISABLED.load(Ordering::Relaxed) {
return;
}
(f)();
} }
// TODO: Refactor // TODO: Refactor
+6 -1
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use process::{ use process::{
PROCESS_MANAGER, PROCESS_MANAGER,
error::ProcessError, error::ProcessError,
process_manager::{LaunchOption, ProcessManager}, process_manager::{LaunchOption, ProcessHandlerOption, ProcessManager},
}; };
use serde::Serialize; use serde::Serialize;
use tauri::AppHandle; use tauri::AppHandle;
@@ -16,6 +16,11 @@ pub fn get_launch_options(id: String) -> Result<Vec<LaunchOption>, ProcessError>
Ok(launch_options) Ok(launch_options)
} }
#[tauri::command]
pub fn get_process_handlers(id: String) -> Result<Vec<ProcessHandlerOption>, ProcessError> {
PROCESS_MANAGER.lock().get_process_handlers(id)
}
#[derive(Serialize)] #[derive(Serialize)]
#[serde(tag = "result", content = "data")] #[serde(tag = "result", content = "data")]
pub enum LaunchResult { pub enum LaunchResult {
+327 -194
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -29,8 +29,8 @@
"@nuxt/image": "^1.10.0", "@nuxt/image": "^1.10.0",
"@nuxt/kit": "^3.20.1", "@nuxt/kit": "^3.20.1",
"@nuxtjs/i18n": "^9.5.5", "@nuxtjs/i18n": "^9.5.5",
"@prisma/adapter-pg": "^7.3.0", "@prisma/adapter-pg": "7.7.0",
"@prisma/client": "^7.3.0", "@prisma/client": "7.7.0",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2", "@simplewebauthn/server": "^13.2.2",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
@@ -58,7 +58,7 @@
"parse-cosekey": "^1.0.2", "parse-cosekey": "^1.0.2",
"pino": "^9.14.0", "pino": "^9.14.0",
"pino-pretty": "^13.1.1", "pino-pretty": "^13.1.1",
"prisma": "7.3.0", "prisma": "7.7.0",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"semver": "^7.7.1", "semver": "^7.7.1",
"shescape": "^2.1.10", "shescape": "^2.1.10",
@@ -89,7 +89,7 @@ Drop's [dockerfile](https://github.com/Drop-OSS/drop/blob/develop/Dockerfile) pr
::: :::
```bash ```bash
npm install prisma@7.3.0 dotenv # dotenv is required npm install prisma@7.7.0 dotenv # dotenv is required
``` ```
Then, with your database running: Then, with your database running:
@@ -21,12 +21,40 @@ Then, what happens with this, depends on the type of game we're launching:
## Normal (no emulator) ## Normal (no emulator)
Drop reconstructs the original shell string, and passes it into platform-specific command wrappers. For Windows, this means nothing. For Linux, it gets wrapped in `umu-run`. Drop reconstructs the original shell string, and passes it into a platform-specific command wrapper, called a **launch method**. Drop picks a sensible launch method automatically, but you can override it per-game for troubleshooting — see [Launch methods](#launch-methods) below.
By default, on Windows the command is launched based on its file type: `.exe` files run directly, `.bat` and `.cmd` files run through `cmd`, `.ps1` files run through PowerShell, and anything else is handed to `cmd` so builtins, `PATHEXT` resolution and `%VAR%` expansion all work. On Linux, native games run directly on the host, while games targeting Windows are wrapped in `umu-run` (with Proton).
It is then parsed again, and then passed into process creation, mapping the environment variable, command, and arguments into their respective platform-dependent places. It is then parsed again, and then passed into process creation, mapping the environment variable, command, and arguments into their respective platform-dependent places.
Drop logs out it's final parsed command, if you want to look at it in the client logs. Drop logs out it's final parsed command, if you want to look at it in the client logs.
## Launch methods
The wrapper Drop uses to start a game is called a **launch method** (a *process handler* internally). Drop automatically selects the best available method for each game, but if a game won't launch you can override it under **Game Options → Launch → Launch method**.
Only methods supported by your current platform (and the game's target platform) are listed, each with a short description in the client.
### Windows
| Method | Description |
| ------ | ----------- |
| **Automatic** *(default)* | Detects the file type and launches it directly, or through `cmd` or PowerShell. |
| **Direct executable** | Runs the executable directly, without a shell. |
| **Command Prompt (cmd)** | Launches through `cmd.exe`. Supports batch files, builtins and `%VAR%` expansion. |
| **PowerShell** | Runs the command as a PowerShell script (`-File`). |
### Linux
| Method | Description |
| ------ | ----------- |
| **Native (direct)** *(default for Linux games)* | Runs the native Linux game directly on the host. |
| **Steam Linux Runtime (umu-run)** | Runs the native Linux game inside `umu-run`'s Steam Linux Runtime. Requires [UMU launcher](/user/usage/proton/). |
| **Proton (umu-run)** *(default for Windows games)* | Runs a Windows game through Proton, using `umu-run`. Requires [Proton](/user/usage/proton/). |
| **Proton + muvm (Asahi)** | Runs a Windows game through Proton inside a muvm microVM, for Apple Silicon / Asahi Linux. |
On macOS, games are always launched directly.
## Emulators ## Emulators
For emulators, we have the "emulator version" (version containing the emulator), and the "emulated version" (version containing the ROM). For emulators, we have the "emulator version" (version containing the emulator), and the "emulated version" (version containing the ROM).
@@ -56,3 +56,7 @@ To launch any Windows game, you **must** first set a default Proton version.
Drop uses a global default Proton version to launch games by default. You can override this in a game's options. Drop uses a global default Proton version to launch games by default. You can override this in a game's options.
![Screenshot showing how to override the proton version](./proton-options-override.png) ![Screenshot showing how to override the proton version](./proton-options-override.png)
## Choosing a launch method
Proton isn't the only thing you can change per-game. If a game won't start, you can also try a different **launch method** from the same **Game Options → Launch** menu — for example, forcing a Windows game through Proton, or running a native Linux game inside the Steam Linux Runtime. See [Launch methods](/reference/command-parsing/#launch-methods) for the full list.