Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ jobs:
- name: Frontend type and Svelte checks
run: bun run check

- name: Frontend tests (texts)
run: bun run test

- name: Rust tests
run: cargo test --manifest-path src-tauri/Cargo.toml --locked

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

# Paraglide (generated from messages/*.json)
/src/lib/paraglide
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,21 @@ Why both exist: AppImage bundles a large part of its own userspace libraries, so
bun run check
```

### 🌍 Texts and languages

All texts live in `messages/de.json` and `messages/en.json`
([Paraglide JS](https://inlang.com/m/gerre34r/library-inlang-paraglideJs)); `bun run i18n` compiles them to
`src/lib/paraglide` (dev, build, check and test do that on their own). Components call them directly,
`m.gameForm_tabCount({ count })`; an id chosen at runtime goes through `pickMsg(msgGroup.wineTools_label, id)`.

The backend does not translate: a command's error is a structured `AppError` (`{ code, ...fields }`), which the
frontend shows as the message `backendErrors_<code>` with the fields as placeholders. The few texts Rust shows
itself — the tray menu and native dialogs — come from the same two files, built into the binary
(`locale::text`).

`bun run test` checks that both languages have the same keys and placeholders, that every message is used and
every used key exists; `cargo test` checks that every `AppError` variant has its message.

## 🧰 Troubleshooting

### ⚠️ AppImage crashes ("EGL_BAD_PARAMETER") or shows only a blank white window
Expand Down
172 changes: 172 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

436 changes: 436 additions & 0 deletions messages/de.json

Large diffs are not rendered by default.

436 changes: 436 additions & 0 deletions messages/en.json

Large diffs are not rendered by default.

16 changes: 11 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
"description": "",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"i18n": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide",
"dev": "bun run i18n && vite dev",
"build": "bun run i18n && vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check": "bun run i18n && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri"
"tauri": "tauri",
"test": "bun run i18n && vitest run"
},
"license": "MIT",
"dependencies": {
Expand All @@ -19,13 +21,17 @@
"@tauri-apps/plugin-opener": "^2"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.25.4",
"@inlang/plugin-message-format": "^4.4.4",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.65.1",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tauri-apps/cli": "^2.12.0",
"@types/node": "^26.6.4",
"svelte": "^5.56.3",
"svelte-check": "^4.6.0",
"typescript": "~6.0.3",
"vite": "^8.3.1"
"vite": "^8.3.1",
"vitest": "^5.0.3"
}
}
9 changes: 9 additions & 0 deletions project.inlang/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "de",
"locales": ["de", "en"],
"modules": ["./node_modules/@inlang/plugin-message-format/dist/index.js"],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}
7 changes: 2 additions & 5 deletions src-tauri/src/commands/games.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::commands::github::read_token;
use crate::commands::graphics_layers::{ensure_directx_layer_cache, ensure_wine_mono_msi};
use crate::commands::icons::{exe_icon_path, extract_icon_png, png_data_url, store_exe_icon};
use crate::commands::logs::{game_log_dir, new_log_file, prefix_log_dir};
use crate::locale::{Locale, LocaleState};
use crate::locale::LocaleState;
use crate::commands::graphics::{ensure_vkbasalt_conf, vkbasalt_conf_path};
use crate::commands::mangohud::{ensure_mangohud_conf, mangohud_conf_path};
use crate::commands::runners::{
Expand Down Expand Up @@ -1161,10 +1161,7 @@ pub fn launch_game_headless(app: &AppHandle, id: String) {
if let Some(dir) = logs_dir {
text.push_str(&format!("\n\nLogs: {}", dir.display()));
}
let title = match locale {
Locale::De => "Spiel konnte nicht gestartet werden",
Locale::En => "Game could not be started",
};
let title = crate::locale::text(locale, "native_launch_title", &[]);
let dialog = app
.dialog()
.message(text)
Expand Down
166 changes: 19 additions & 147 deletions src-tauri/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ use crate::locale::Locale;
/// as before (mostly technical/OS errors, e.g. "Could not read X: <os
/// err>") — the frontend shows those as-is, unlocalized, same as today.
/// The other variants carry just the data the situation needs, so the
/// frontend can render the sentence in the UI's current language from its
/// own i18n dictionaries (see `backendErrors` in `src/lib/i18n`). Add a
/// variant here — and a matching `backendErrors.<code>` entry in `de.ts`/
/// `en.ts`, and an arm in `localized()` below — for any error a user can
/// frontend can render the sentence in the UI's current language (see
/// `backendError()` in `src/lib/i18n`). Add a variant here — and a matching
/// `backendErrors_<code>` message in `messages/{de,en}.json` whose
/// placeholders are the variant's fields — for any error a user can
/// plausibly hit and should read in their chosen language, rather than
/// leaving it to fall through to `Other`.
/// leaving it to fall through to `Other`. `every_app_error_has_a_text`
/// checks that the message exists.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "code", rename_all = "snake_case")]
pub enum AppError {
Expand Down Expand Up @@ -100,148 +101,19 @@ impl AppError {
/// runs in at all. Everywhere else, translate `code` (see `t()` /
/// `backendError()` in `src/lib/i18n`) instead of calling this.
pub fn localized(&self, locale: Locale) -> String {
use Locale::{De, En};
match (self, locale) {
(AppError::Other { message }, _) => message.clone(),
(AppError::GameAlreadyRunning, De) => {
"Das Spiel wird bereits gestartet oder läuft schon".into()
}
(AppError::GameAlreadyRunning, En) => {
"The game is already starting or running".into()
}
(AppError::PrefixInUse { game_name }, De) => format!(
"„{game_name}“ läuft noch in diesem Prefix. Beende das Spiel zuerst."
),
(AppError::PrefixInUse { game_name }, En) => format!(
"“{game_name}” is still running in this prefix. Close the game first."
),
(AppError::SteamCommandNotFound, De) => {
"Steam läuft. Bitte beende Steam und versuche es erneut.".into()
}
(AppError::SteamCommandNotFound, En) => {
"Steam is running. Please quit Steam and try again.".into()
}
(AppError::SteamShutdownTimedOut, De) => {
"Steam hat sich nicht innerhalb von 30 Sekunden beendet.".into()
}
(AppError::SteamShutdownTimedOut, En) => "Steam didn't quit within 30 seconds.".into(),
(AppError::SteamOnlyFlatpak, De) => "Steam ist nur als Flatpak installiert. Das \
Flatpak darf keine Programme außerhalb seiner Sandbox starten, also auch \
Prefixr nicht."
.into(),
(AppError::SteamOnlyFlatpak, En) => "Steam is only installed as a Flatpak. The \
Flatpak isn't allowed to launch programs outside its sandbox, so it can't \
launch Prefixr either."
.into(),
(AppError::SteamNotFound, De) => {
"Steam wurde nicht gefunden. Starte Steam einmal und melde dich an.".into()
}
(AppError::SteamNotFound, En) => {
"Steam wasn't found. Start Steam once and sign in.".into()
}
(AppError::NoSteamAccountFound, De) => {
"Kein Steam-Konto gefunden. Melde dich einmal in Steam an.".into()
}
(AppError::NoSteamAccountFound, En) => {
"No Steam account found. Sign in to Steam once.".into()
}
(AppError::ShortcutsVdfUnreadable { error }, De) => format!(
"shortcuts.vdf konnte nicht gelesen werden ({error}). Prefixr lässt die Datei \
deshalb unverändert."
),
(AppError::ShortcutsVdfUnreadable { error }, En) => format!(
"shortcuts.vdf could not be read ({error}). Prefixr is leaving the file \
unchanged."
),
(AppError::NoPackagesSelected, De) => "Keine Pakete ausgewählt".into(),
(AppError::NoPackagesSelected, En) => "No packages selected".into(),
(AppError::UnclosedQuoteInLaunchArgs, De) => {
"Startargumente: ein Anführungszeichen wird nicht geschlossen".into()
}
(AppError::UnclosedQuoteInLaunchArgs, En) => {
"Launch arguments: a quotation mark is left unclosed".into()
}
(AppError::PkexecNotInstalled { command }, De) => {
format!("pkexec ist nicht installiert. Bitte manuell ausführen: {command}")
}
(AppError::PkexecNotInstalled { command }, En) => {
format!("pkexec is not installed. Please run manually: {command}")
}
(AppError::SysctlChangeFailed { status }, De) => {
format!("sysctl-Anpassung fehlgeschlagen oder abgebrochen (Status {status})")
}
(AppError::SysctlChangeFailed { status }, En) => {
format!("sysctl change failed or was cancelled (status {status})")
}
(AppError::RunnerInUse { runner_name, games }, De) => {
format!("{runner_name} wird noch verwendet von {games}.")
}
(AppError::RunnerInUse { runner_name, games }, En) => {
format!("{runner_name} is still used by {games}.")
}
(AppError::WithLogDetails { message, log_path }, De) => {
format!("{message} — Details im Log: {log_path}")
}
(AppError::WithLogDetails { message, log_path }, En) => {
format!("{message} — details in the log: {log_path}")
}
(AppError::WinetricksFailed { status, log_path }, De) => format!(
"winetricks beendete sich mit Status {status} — Details im Log: {log_path}"
),
(AppError::WinetricksFailed { status, log_path }, En) => format!(
"winetricks exited with status {status} — details in the log: {log_path}"
),
(AppError::PrefixAlreadyExists { path }, De) => {
format!("Prefix unter {path} existiert bereits")
}
(AppError::PrefixAlreadyExists { path }, En) => {
format!("Prefix at {path} already exists")
}
(AppError::PathNotADirectory { path }, De) => {
format!("{path} ist kein Ordner")
}
(AppError::PathNotADirectory { path }, En) => {
format!("{path} is not a directory")
}
(AppError::PrefixDirNotEmpty { path }, De) => format!(
"{path} enthält bereits Dateien, sieht aber nicht wie ein Wine- oder \
Proton-Prefix aus"
),
(AppError::PrefixDirNotEmpty { path }, En) => format!(
"{path} already contains files but doesn't look like a Wine or Proton prefix"
),
(AppError::RunnerAlreadyExists { tag }, De) => {
format!("Runner „{tag}“ existiert bereits")
}
(AppError::RunnerAlreadyExists { tag }, En) => {
format!("Runner '{tag}' already exists")
}
(AppError::GitHubRateLimited, De) => "GitHubs Limit für nicht angemeldete Anfragen \
ist ausgeschöpft — ein GitHub-Token in den Einstellungen hebt es an."
.into(),
(AppError::GitHubRateLimited, En) => "GitHub's rate limit for unauthenticated \
requests is exhausted — add a GitHub token in the settings to raise it."
.into(),
(AppError::GitHubApiError { status }, De) => {
format!("GitHub-API antwortete mit Status {status}")
}
(AppError::GitHubApiError { status }, En) => {
format!("GitHub API returned status {status}")
}
(AppError::ToolLaunchFailed { tool, error }, De) => {
format!("Konnte {tool} nicht starten: {error}")
}
(AppError::ToolLaunchFailed { tool, error }, En) => {
format!("Could not start {tool}: {error}")
}
(AppError::SetupLaunchFailed { error, log_path }, De) => format!(
"Setup konnte nicht gestartet werden: {error} — Details im Log: {log_path}"
),
(AppError::SetupLaunchFailed { error, log_path }, En) => format!(
"Setup could not be started: {error} — details in the log: {log_path}"
),
(AppError::UnknownWineTool { tool }, De) => format!("Unbekanntes Wine-Werkzeug: {tool}"),
(AppError::UnknownWineTool { tool }, En) => format!("Unknown Wine tool: {tool}"),
if let AppError::Other { message } = self {
return message.clone();
}
// The same text the frontend shows: `backendErrors_<code>` with the
// variant's fields as placeholders (see `serde(tag = "code")` above).
let value = serde_json::to_value(self).expect("AppError serializes");
let fields = value.as_object().expect("AppError is an object");
let code = fields["code"].as_str().unwrap_or_default();
let params: Vec<(&str, &str)> = fields
.iter()
.filter(|(k, _)| k.as_str() != "code")
.map(|(k, v)| (k.as_str(), v.as_str().unwrap_or_default()))
.collect();
crate::locale::text(locale, &format!("backendErrors_{code}"), &params)
}
}
41 changes: 9 additions & 32 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ use commands::winetricks::{
install_winetricks_verbs, list_all_winetricks_verbs, list_installed_winetricks_verbs,
};
use config::load_config;
use locale::{Locale, LocaleState};
use locale::LocaleState;
use tray::{
hide_main_window, rebuild_tray_menu, setup_tray, show_and_focus, tray_host_available,
TrayAvailable, WindowVisible,
Expand Down Expand Up @@ -142,21 +142,8 @@ pub fn run() {
let startup_locale = app.state::<LocaleState>().get();

if running_as_root() {
let (message, title) = match startup_locale {
Locale::De => (
"Ein als root angelegter oder gestarteter Wine-Prefix gehört danach \
root statt dir — deine normale Sitzung kann ihn dann oft nicht mehr \
verwenden oder ohne sudo löschen. Bitte Prefixr als normaler Benutzer \
starten.",
"Prefixr nicht als root ausführen",
),
Locale::En => (
"A Wine prefix created or run as root ends up owned by root instead of \
you — your normal session then often can't use it anymore, or delete \
it without sudo. Please start Prefixr as a normal user.",
"Don't run Prefixr as root",
),
};
let message = locale::text(startup_locale, "native_root_message", &[]);
let title = locale::text(startup_locale, "native_root_title", &[]);
app.dialog()
.message(message)
.title(title)
Expand All @@ -171,22 +158,12 @@ pub fn run() {
let config = match load_config(app.handle()) {
Ok(config) => config,
Err(message) => {
let (body, title) = match startup_locale {
Locale::De => (
format!(
"{message}\n\nPrefixr lässt die Datei unverändert. Repariere \
oder entferne sie und starte Prefixr dann neu."
),
"Konfiguration konnte nicht gelesen werden",
),
Locale::En => (
format!(
"{message}\n\nPrefixr is leaving the file unchanged. Fix or \
remove it and restart Prefixr."
),
"Could not read the configuration",
),
};
let body = locale::text(
startup_locale,
"native_config_message",
&[("error", &message)],
);
let title = locale::text(startup_locale, "native_config_title", &[]);
app.dialog()
.message(body)
.title(title)
Expand Down
33 changes: 32 additions & 1 deletion src-tauri/src/locale.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::sync::Mutex;
use std::sync::{Mutex, OnceLock};

/// The UI language for the handful of things Rust itself renders directly
/// (the tray menu, and a few native dialogs shown before the frontend has
Expand Down Expand Up @@ -57,5 +57,36 @@ impl LocaleState {
}
}

/// The UI's own texts (`messages/*.json`, the same files the frontend compiles with Paraglide),
/// built into the binary so the tray and the native dialogs need no copy of their own.
const MESSAGES_DE: &str = include_str!("../../messages/de.json");
const MESSAGES_EN: &str = include_str!("../../messages/en.json");

type Messages = serde_json::Map<String, serde_json::Value>;

fn messages(locale: Locale) -> &'static Messages {
static DE: OnceLock<Messages> = OnceLock::new();
static EN: OnceLock<Messages> = OnceLock::new();
let (cell, source) = match locale {
Locale::De => (&DE, MESSAGES_DE),
Locale::En => (&EN, MESSAGES_EN),
};
cell.get_or_init(|| serde_json::from_str(source).expect("messages/*.json is valid JSON"))
}

/// A text by key with its `{placeholders}` filled in, e.g.
/// `text(locale, "native_tray_killGame", &[("name", "Doom")])`. Only plain messages (no plurals
/// or variants); an unknown key comes back as the key itself.
pub fn text(locale: Locale, key: &str, params: &[(&str, &str)]) -> String {
let Some(template) = messages(locale).get(key).and_then(|v| v.as_str()) else {
return key.to_string();
};
let mut out = template.to_string();
for (name, value) in params {
out = out.replace(&format!("{{{name}}}"), value);
}
out.replace("\\{", "{").replace("\\}", "}")
}

#[cfg(test)]
mod tests;
Loading
Loading