diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs new file mode 100644 index 00000000..69f4a49a --- /dev/null +++ b/src-tauri/src/constants.rs @@ -0,0 +1,7 @@ +pub const MAIN_WINDOW_LABEL: &str = "main"; +pub const POPOUT_WINDOW_LABEL_PREFIX: &str = "popout"; +pub const APP_TITLE: &str = "LiveSplit One"; +pub const INDEX_HTML: &str = "index.html"; +pub const ABOUT_BLANK: &str = "about:blank"; +pub const MAIN_WINDOW_WIDTH: f64 = 850.0; +pub const MAIN_WINDOW_HEIGHT: f64 = 750.0; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d5d98959..fb3c8803 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,7 +4,10 @@ use std::{ borrow::Cow, future::Future, str::FromStr, - sync::{Arc, RwLock}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, RwLock, + }, }; use livesplit_core::{ @@ -13,7 +16,16 @@ use livesplit_core::{ networking::server_protocol::Command, HotkeyConfig, HotkeySystem, StoredAutoSplitterSettings, TimeSpan, TimingMethod, }; -use tauri::{Emitter, Manager, WebviewWindow}; +use tauri::{ + webview::NewWindowResponse, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder, + WindowEvent, +}; + +mod constants; +use constants::{ + ABOUT_BLANK, APP_TITLE, INDEX_HTML, MAIN_WINDOW_HEIGHT, MAIN_WINDOW_LABEL, MAIN_WINDOW_WIDTH, + POPOUT_WINDOW_LABEL_PREFIX, +}; struct State { hotkey_system: RwLock>>, @@ -225,7 +237,47 @@ fn main() { window: RwLock::new(None), }) .setup(move |app| { - let main_window = app.webview_windows().values().next().unwrap().clone(); + let app_handle = app.handle().clone(); + let popout_counter = Arc::new(AtomicUsize::new(0)); + let popout_counter_for_handler = popout_counter.clone(); + + let main_window = WebviewWindowBuilder::new( + app, + MAIN_WINDOW_LABEL, + WebviewUrl::App(INDEX_HTML.into()), + ) + .title(APP_TITLE) + .inner_size(MAIN_WINDOW_WIDTH, MAIN_WINDOW_HEIGHT) + .use_https_scheme(true) + // WebKitGTK can return `null` for the browser-managed `window.open` + // flow. Creating the requested window here keeps the frontend code + // portable while giving Tauri full control over Linux popouts. + .on_new_window(move |_url, features| { + let label = format!( + "{POPOUT_WINDOW_LABEL_PREFIX}-{}", + popout_counter_for_handler.fetch_add(1, Ordering::Relaxed) + ); + let builder = WebviewWindowBuilder::new( + &app_handle, + label, + WebviewUrl::External(ABOUT_BLANK.parse().unwrap()), + ) + .window_features(features) + .on_document_title_changed(|window, title| { + let _ = window.set_title(&title); + }) + .title(APP_TITLE); + + match builder.build() { + Ok(window) => NewWindowResponse::Create { window }, + Err(err) => { + eprintln!("failed to create popout window: {err}"); + NewWindowResponse::Deny + } + } + }) + .build()?; + app.state::() .window .write() @@ -234,6 +286,23 @@ fn main() { *sink.0.write().unwrap() = Some(main_window); Ok(()) }) + .on_window_event(|window, event| { + if !matches!(event, WindowEvent::Destroyed) || window.label() != MAIN_WINDOW_LABEL { + return; + } + + let popouts: Vec<_> = window + .app_handle() + .webview_windows() + .into_iter() + .filter(|(label, _)| label.starts_with(POPOUT_WINDOW_LABEL_PREFIX)) + .map(|(_, popout)| popout) + .collect(); + + for popout in popouts { + let _ = popout.destroy(); + } + }) .invoke_handler(tauri::generate_handler![ set_hotkey_config, set_hotkey_activation, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 96cb84ca..7089b001 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -20,14 +20,7 @@ "plugins": {}, "app": { "withGlobalTauri": true, - "windows": [ - { - "title": "LiveSplit One", - "width": 850, - "height": 750, - "useHttpsScheme": true - } - ], + "windows": [], "security": { "csp": null } diff --git a/src/localization/chinese-simplified.ts b/src/localization/chinese-simplified.ts index 761929de..d25a1e09 100644 --- a/src/localization/chinese-simplified.ts +++ b/src/localization/chinese-simplified.ts @@ -114,6 +114,8 @@ export function resolveChineseSimplified(text: Label): string { return "游戏时间"; case Label.PopOut: return "弹出窗口"; + case Label.PopOutFailed: + return "无法打开新窗口。"; case Label.About: return "关于"; case Label.Back: diff --git a/src/localization/dutch.ts b/src/localization/dutch.ts index e890020b..6bc9e064 100644 --- a/src/localization/dutch.ts +++ b/src/localization/dutch.ts @@ -114,6 +114,8 @@ export function resolveDutch(text: Label): string { return "Speltijd"; case Label.PopOut: return "Pop‑out"; + case Label.PopOutFailed: + return "Kan geen nieuw venster openen."; case Label.About: return "Over"; case Label.Back: diff --git a/src/localization/english.ts b/src/localization/english.ts index ca0b9c44..8c865e7f 100644 --- a/src/localization/english.ts +++ b/src/localization/english.ts @@ -114,6 +114,8 @@ export function resolveEnglish(text: Label): string { return "Game Time"; case Label.PopOut: return "Pop Out"; + case Label.PopOutFailed: + return "Failed to open a new window."; case Label.About: return "About"; case Label.Back: diff --git a/src/localization/french.ts b/src/localization/french.ts index 9e13cb62..c8beed71 100644 --- a/src/localization/french.ts +++ b/src/localization/french.ts @@ -114,6 +114,8 @@ export function resolveFrench(text: Label): string { return "Temps de jeu"; case Label.PopOut: return "Fenêtre détachée"; + case Label.PopOutFailed: + return "Impossible d’ouvrir une nouvelle fenêtre."; case Label.About: return "À propos"; case Label.Back: diff --git a/src/localization/german.ts b/src/localization/german.ts index d8c6f88f..55fe949d 100644 --- a/src/localization/german.ts +++ b/src/localization/german.ts @@ -114,6 +114,8 @@ export function resolveGerman(text: Label): string { return "Spielzeit"; case Label.PopOut: return "Pop‑out"; + case Label.PopOutFailed: + return "Neues Fenster konnte nicht geöffnet werden."; case Label.About: return "Über"; case Label.Back: diff --git a/src/localization/index.ts b/src/localization/index.ts index 2dec3e5f..40392452 100644 --- a/src/localization/index.ts +++ b/src/localization/index.ts @@ -71,6 +71,7 @@ export enum Label { RealTime, GameTime, PopOut, + PopOutFailed, About, Back, AboutVersionPrefix, diff --git a/src/localization/italian.ts b/src/localization/italian.ts index 57fcdc4a..ad0b8ea5 100644 --- a/src/localization/italian.ts +++ b/src/localization/italian.ts @@ -114,6 +114,8 @@ export function resolveItalian(text: Label): string { return "Tempo di gioco"; case Label.PopOut: return "Finestra separata"; + case Label.PopOutFailed: + return "Impossibile aprire una nuova finestra."; case Label.About: return "Informazioni"; case Label.Back: diff --git a/src/localization/japanese.ts b/src/localization/japanese.ts index c289dae1..50e01f06 100644 --- a/src/localization/japanese.ts +++ b/src/localization/japanese.ts @@ -114,6 +114,8 @@ export function resolveJapanese(text: Label): string { return "ゲーム時間"; case Label.PopOut: return "ポップアウト"; + case Label.PopOutFailed: + return "新しいウィンドウを開けませんでした。"; case Label.About: return "このアプリについて"; case Label.Back: diff --git a/src/localization/korean.ts b/src/localization/korean.ts index 5c33ddf4..b402b9c2 100644 --- a/src/localization/korean.ts +++ b/src/localization/korean.ts @@ -114,6 +114,8 @@ export function resolveKorean(text: Label): string { return "게임 시간"; case Label.PopOut: return "팝아웃"; + case Label.PopOutFailed: + return "새 창을 열지 못했습니다."; case Label.About: return "정보"; case Label.Back: diff --git a/src/localization/polish.ts b/src/localization/polish.ts index 3e3d031d..f7375a49 100644 --- a/src/localization/polish.ts +++ b/src/localization/polish.ts @@ -114,6 +114,8 @@ export function resolvePolish(text: Label): string { return "Czas gry"; case Label.PopOut: return "Otwórz w oknie"; + case Label.PopOutFailed: + return "Nie udało się otworzyć nowego okna."; case Label.About: return "O programie"; case Label.Back: diff --git a/src/localization/portuguese-brazil.ts b/src/localization/portuguese-brazil.ts index 4c39896d..a488895f 100644 --- a/src/localization/portuguese-brazil.ts +++ b/src/localization/portuguese-brazil.ts @@ -114,6 +114,8 @@ export function resolveBrazilianPortuguese(text: Label): string { return "Tempo de jogo"; case Label.PopOut: return "Pop‑out"; + case Label.PopOutFailed: + return "Não foi possível abrir uma nova janela."; case Label.About: return "Sobre"; case Label.Back: diff --git a/src/localization/portuguese.ts b/src/localization/portuguese.ts index 87775d4f..c66f357e 100644 --- a/src/localization/portuguese.ts +++ b/src/localization/portuguese.ts @@ -114,6 +114,8 @@ export function resolvePortuguese(text: Label): string { return "Tempo de jogo"; case Label.PopOut: return "Pop‑out"; + case Label.PopOutFailed: + return "Não foi possível abrir uma nova janela."; case Label.About: return "Sobre"; case Label.Back: diff --git a/src/localization/russian.ts b/src/localization/russian.ts index 7f67f28a..bb48c6b1 100644 --- a/src/localization/russian.ts +++ b/src/localization/russian.ts @@ -114,6 +114,8 @@ export function resolveRussian(text: Label): string { return "Игровое время"; case Label.PopOut: return "Окно отдельно"; + case Label.PopOutFailed: + return "Не удалось открыть новое окно."; case Label.About: return "О программе"; case Label.Back: diff --git a/src/localization/spanish.ts b/src/localization/spanish.ts index 9441bef0..43e66545 100644 --- a/src/localization/spanish.ts +++ b/src/localization/spanish.ts @@ -114,6 +114,8 @@ export function resolveSpanish(text: Label): string { return "Tiempo de juego"; case Label.PopOut: return "Ventana emergente"; + case Label.PopOutFailed: + return "No se pudo abrir una nueva ventana."; case Label.About: return "Acerca de"; case Label.Back: diff --git a/src/ui/LiveSplit.tsx b/src/ui/LiveSplit.tsx index 0442c4e3..fd356e85 100644 --- a/src/ui/LiveSplit.tsx +++ b/src/ui/LiveSplit.tsx @@ -1294,12 +1294,11 @@ async function openPopupWindow( // It's fine if it fails, as not all browsers support this. } - const childWindow = window.open( - "", - "_blank", - `popup,width=${width},height=${height}`, - ); - return childWindow; + try { + return window.open("about:blank", "_blank"); + } catch { + return null; + } } async function popOut( @@ -1311,6 +1310,9 @@ async function popOut( ) { const childWindow = await openPopupWindow(width, height); if (!childWindow) { + toast.error( + `${resolve(Label.PopOut, generalSettings.lang)}: ${resolve(Label.PopOutFailed, generalSettings.lang)}`, + ); return; }