From 2087bcae25cf0fca2f944777cdadfef773639f19 Mon Sep 17 00:00:00 2001 From: Xia Chao Date: Mon, 31 Aug 2026 15:21:05 +0800 Subject: [PATCH 1/3] fix(path): return "." from path.normalize("") (#15920) * fix(path): return "." from normalize("") Match Node.js and the existing join("") contract already documented in the normalize comment. * test(path): simplify normalize empty-string assertions --------- Co-authored-by: Xia Chao <236466140+bun-unsafe@users.noreply.github.com> --- .changes/path-normalize-empty.md | 5 +++++ crates/tauri/src/path/plugin.rs | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .changes/path-normalize-empty.md diff --git a/.changes/path-normalize-empty.md b/.changes/path-normalize-empty.md new file mode 100644 index 000000000000..bb8261bd18a1 --- /dev/null +++ b/.changes/path-normalize-empty.md @@ -0,0 +1,5 @@ +--- +tauri: patch:bug +--- + +Fix `path.normalize("")` returning an empty string instead of `"."`, matching Node.js and the existing `path.join("")` behavior. diff --git a/crates/tauri/src/path/plugin.rs b/crates/tauri/src/path/plugin.rs index e65e5ddfdc2c..b095f338200e 100644 --- a/crates/tauri/src/path/plugin.rs +++ b/crates/tauri/src/path/plugin.rs @@ -120,7 +120,7 @@ pub fn normalize(path: String) -> String { // and `"."` for `normalize("")` or `normalize(".")` if p.is_empty() && path == ".." { "..".into() - } else if p.is_empty() && path == "." { + } else if p.is_empty() && (path.is_empty() || path == ".") { ".".into() } else { // Add a trailing separator if the path passed to this functions had a trailing separator. That's how Node.js behaves. @@ -310,4 +310,11 @@ mod tests { check(vec!["a", "b/c", "d"], "a/b/c/d", r"a\b\c\d"); check(vec!["a/", "b"], "a/b", r"a\b"); } + + #[test] + fn normalize() { + assert_eq!(super::normalize("".into()), "."); + assert_eq!(super::normalize(".".into()), "."); + assert_eq!(super::normalize("..".into()), ".."); + } } From f4feb2ba71ae018f0fc200453ae9cf12a6b0c05f Mon Sep 17 00:00:00 2001 From: Tony <68118705+Legend-Master@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:09:54 +0800 Subject: [PATCH 2/3] fix(windows): use cached focus states for `is_focused` (#15950) * fix(windows): use cached focus states for `is_focused` * fmt * Add change file --- ...dows-multiwebview-cached-focused-states.md | 6 ++++++ crates/tauri-runtime-wry/src/lib.rs | 19 +++++++++++++++++-- crates/tauri/src/manager/mod.rs | 6 +++--- 3 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 .changes/windows-multiwebview-cached-focused-states.md diff --git a/.changes/windows-multiwebview-cached-focused-states.md b/.changes/windows-multiwebview-cached-focused-states.md new file mode 100644 index 000000000000..22e8afc482ea --- /dev/null +++ b/.changes/windows-multiwebview-cached-focused-states.md @@ -0,0 +1,6 @@ +--- +tauri: patch:bug +tauri-runtime-wry: patch:bug +--- + +On Windows, fixed `Window::is_focused` always returns `false` in multi-webview mode diff --git a/crates/tauri-runtime-wry/src/lib.rs b/crates/tauri-runtime-wry/src/lib.rs index d14f6570ded6..0470b036d943 100644 --- a/crates/tauri-runtime-wry/src/lib.rs +++ b/crates/tauri-runtime-wry/src/lib.rs @@ -3317,9 +3317,12 @@ fn handle_user_message( w.webviews.clone(), w.has_children.load(Ordering::Relaxed), w.window_event_listeners.clone(), + w.focused_webview.clone(), ) }); - if let Some((Some(window), webviews, has_children, window_event_listeners)) = w { + if let Some((Some(window), webviews, has_children, window_event_listeners, focused_webview)) = + w + { match window_message { WindowMessage::AddEventListener(id, listener) => { window_event_listeners.lock().unwrap().insert(id, listener); @@ -3348,7 +3351,19 @@ fn handle_user_message( WindowMessage::IsFullscreen(tx) => tx.send(window.fullscreen().is_some()).unwrap(), WindowMessage::IsMinimized(tx) => tx.send(window.is_minimized()).unwrap(), WindowMessage::IsMaximized(tx) => tx.send(window.is_maximized()).unwrap(), - WindowMessage::IsFocused(tx) => tx.send(window.is_focused()).unwrap(), + WindowMessage::IsFocused(tx) => { + let focused = if has_children { + // on multiwebview mode, get the focused state from cache, + // as the window might not have direct focus + matches!( + *focused_webview.lock().unwrap(), + FocusState::WindowFocused | FocusState::WebviewFocused { .. } + ) + } else { + window.is_focused() + }; + tx.send(focused).unwrap() + } WindowMessage::IsDecorated(tx) => tx.send(window.is_decorated()).unwrap(), WindowMessage::IsResizable(tx) => tx.send(window.is_resizable()).unwrap(), WindowMessage::IsMaximizable(tx) => tx.send(window.is_maximizable()).unwrap(), diff --git a/crates/tauri/src/manager/mod.rs b/crates/tauri/src/manager/mod.rs index a60ac048f911..a8ee394eb36f 100644 --- a/crates/tauri/src/manager/mod.rs +++ b/crates/tauri/src/manager/mod.rs @@ -650,9 +650,9 @@ impl AppManager { self .window .windows_lock() - .iter() - .find(|w| w.1.is_focused().unwrap_or(false)) - .map(|w| w.1.clone()) + .values() + .find(|w| w.is_focused().unwrap_or(false)) + .cloned() } pub(crate) fn on_window_close(&self, label: &str) { From cb2ecac53a505b974ef24f0d4f66ac04fc298eff Mon Sep 17 00:00:00 2001 From: Danila Gornushko Date: Mon, 31 Aug 2026 13:06:32 +0300 Subject: [PATCH 3/3] fix(android): register result launchers per activity (#15949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Destroying an activity while another one was alive crashed the app: java.lang.RuntimeException: Unable to destroy activity {MainActivity} Caused by: java.lang.IllegalStateException: LifecycleOwner MainActivity is attempting to register while current state is RESUMED. LifecycleOwners must call register before they are STARTED. at app.tauri.plugin.PluginManager.onDestroy `onDestroy` handed the manager's launchers to whichever activity was left by calling `registerResultLaunchers` on it, but that activity was already running, and `registerForActivityResult` may only be called before its owner reaches STARTED. There is no moment during another activity's teardown at which the call is legal. So the launchers are no longer a property of the manager that migrates. Each activity registers its own set while it is being created, which is the one point where registering is allowed, and `onDestroy` only has to drop the entry and point at whatever remains — which already has launchers of its own. `LinkedHashMap` rather than a set, so the activity taken over is the oldest survivor and not an arbitrary one. Reproduced on Android 15 by reinstalling an APK over the running app, which momentarily leaves the new activity resumed while the old one is being destroyed; fixed with this change. --- .changes/android-plugin-manager-relaunch.md | 5 ++ .../java/app/tauri/plugin/PluginManager.kt | 73 +++++++++---------- 2 files changed, 40 insertions(+), 38 deletions(-) create mode 100644 .changes/android-plugin-manager-relaunch.md diff --git a/.changes/android-plugin-manager-relaunch.md b/.changes/android-plugin-manager-relaunch.md new file mode 100644 index 000000000000..eeb27f3a6fa0 --- /dev/null +++ b/.changes/android-plugin-manager-relaunch.md @@ -0,0 +1,5 @@ +--- +tauri: patch:bug +--- + +On Android, fixed a crash when an activity is destroyed while another one is already running — installing an APK over the running app is the common way to hit it. The plugin manager tried to move its activity result launchers to the surviving activity, and `registerForActivityResult` rejects that with `IllegalStateException: LifecycleOwner ... is attempting to register while current state is RESUMED`. Each activity now registers its own launchers when it is created. diff --git a/crates/tauri/mobile/android/src/main/java/app/tauri/plugin/PluginManager.kt b/crates/tauri/mobile/android/src/main/java/app/tauri/plugin/PluginManager.kt index 32a1cc519d23..7e5d4264034e 100644 --- a/crates/tauri/mobile/android/src/main/java/app/tauri/plugin/PluginManager.kt +++ b/crates/tauri/mobile/android/src/main/java/app/tauri/plugin/PluginManager.kt @@ -34,12 +34,18 @@ object PluginManager { fun onResult(result: ActivityResult) } - private val activities: HashSet = HashSet() + /** The result launchers belonging to one activity. */ + private class ResultLaunchers( + val startActivityForResult: ActivityResultLauncher, + val startIntentSenderForResult: ActivityResultLauncher, + val requestPermissions: ActivityResultLauncher> + ) + + // Insertion ordered, so the activity taken over when the current one goes away is the oldest + // surviving one rather than an arbitrary member of a hash set. + private val launchers: LinkedHashMap = LinkedHashMap() var activity: AppCompatActivity? = null private val plugins: HashMap = HashMap() - private var startActivityForResultLauncher: ActivityResultLauncher? = null - private var startIntentSenderForResultLauncher: ActivityResultLauncher? = null - private var requestPermissionsLauncher: ActivityResultLauncher>? = null private var requestPermissionsCallback: RequestPermissionsCallback? = null private var startActivityForResultCallback: ActivityResultCallback? = null private var startIntentSenderForResultCallback: ActivityResultCallback? = null @@ -57,39 +63,36 @@ object PluginManager { } fun onCreate(activity: AppCompatActivity) { - // Record the activity, and if that's the only activity we got, register result launchers - activities.add(activity) + // Every activity gets its own launchers, and gets them here: registerForActivityResult must + // be called before its owner reaches STARTED, so an activity that is already running can + // never be given launchers later. + launchers[activity] = registerResultLaunchers(activity) if (this.activity == null) { this.activity = activity - registerResultLaunchers(activity) } } - private fun registerResultLaunchers(activity: AppCompatActivity) { - startActivityForResultLauncher = + private fun registerResultLaunchers(activity: AppCompatActivity): ResultLaunchers = + ResultLaunchers( activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult() ) { result -> - if (startActivityForResultCallback != null) { - startActivityForResultCallback!!.onResult(result) - } - } + startActivityForResultCallback?.onResult(result) + }, - startIntentSenderForResultLauncher = activity.registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult() ) { result -> - if (startIntentSenderForResultCallback != null) { - startIntentSenderForResultCallback!!.onResult(result) - } - } + startIntentSenderForResultCallback?.onResult(result) + }, - requestPermissionsLauncher = activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions() ) { result -> - if (requestPermissionsCallback != null) { - requestPermissionsCallback!!.onResult(result) - } + requestPermissionsCallback?.onResult(result) } - } + ) + + private val currentLaunchers: ResultLaunchers + get() = launchers[activity] + ?: throw IllegalStateException("the plugin manager has no activity to launch from") fun onNewIntent(intent: Intent) { for (plugin in plugins.values) { @@ -126,18 +129,12 @@ object PluginManager { plugin.instance.triggerOnDestroy(activity) } - activities.remove(activity) - val nextActivity = activities.firstOrNull() - if (nextActivity != null) { - if (this.activity == activity) { - this.activity = nextActivity - registerResultLaunchers(nextActivity) - } - } else { - this.activity = null - this.startActivityForResultLauncher = null - this.startIntentSenderForResultLauncher = null - this.requestPermissionsLauncher = null + launchers.remove(activity) + if (this.activity == activity) { + // Whatever is left already holds its own launchers, registered when it was created. Moving + // this activity's launchers over instead would mean registering against an activity that is + // already running, which registerForActivityResult rejects with an IllegalStateException. + this.activity = launchers.keys.firstOrNull() } } @@ -149,12 +146,12 @@ object PluginManager { fun startActivityForResult(intent: Intent, callback: ActivityResultCallback) { startActivityForResultCallback = callback - startActivityForResultLauncher!!.launch(intent) + currentLaunchers.startActivityForResult.launch(intent) } fun startIntentSenderForResult(intent: IntentSenderRequest, callback: ActivityResultCallback) { startIntentSenderForResultCallback = callback - startIntentSenderForResultLauncher!!.launch(intent) + currentLaunchers.startIntentSenderForResult.launch(intent) } fun requestPermissions( @@ -162,7 +159,7 @@ object PluginManager { callback: RequestPermissionsCallback ) { requestPermissionsCallback = callback - requestPermissionsLauncher!!.launch(permissionStrings) + currentLaunchers.requestPermissions.launch(permissionStrings) } @JniMethod