Skip to content

iOS: cold-start URL/document opens are dropped — connect_scene ignores connectionOptions.URLContexts #1248

Description

@bgrundmann

Summary

Under the scene lifecycle, an iOS app launched by a URL or document open (custom scheme, universal link, or a CFBundleDocumentTypes "Open with" file) never receives the URL. tao emits Event::Opened only for warm opens (app already running); cold-start opens are silently dropped.

Environment

Root cause

iOS delivers the launch URL two different ways under the scene lifecycle:

  • App already runningscene(_:openURLContexts:). tao handles this: TaoSceneDelegate::scene_openURLContexts parses the URLs and emits Event::Opened ✅ (src/platform_impl/ios/scene.rs).
  • App not running (cold start) → the URLs arrive in scene(_:willConnectTo:options:) via connectionOptions.URLContexts. tao's connect_scene (src/platform_impl/ios/app_state.rs) sets up the window/scene but never reads options.URLContexts(), so no Event::Opened is emitted ❌.

There's no app-delegate fallback either: for scene-based apps the launch URL is not placed in application(_:didFinishLaunchingWithOptions:)'s launchOptions, so once the scene callback ignores it, it's unrecoverable.

Expected vs actual

  • Expected: a cold-start open emits Event::Opened { urls }, matching the warm path.
  • Actual: nothing is emitted; the app launches with no indication a URL/file triggered it.

Impact

  • tauri-plugin-deep-link's getCurrent() returns null on cold start and onOpenUrl never fires — deep links / universal links / document opens only work if the app is already running. Affects every Tauri iOS app using the plugin.
  • Document-based apps (CFBundleDocumentTypes / "Open with") can't receive a file when launched by it.

Repro

  1. Scene-lifecycle iOS app that logs Event::Opened.
  2. Register a custom scheme (or a document type).
  3. Fully quit the app; trigger the scheme / "Open with" from another app.
  4. App launches; Event::Opened never fires. (Warm — app already open — works.)

Possible fix

Extract the URL-parsing/emit from scene:openURLContexts: into a shared helper, and call it from connect_scene on options.URLContexts() so cold and warm opens are symmetric. If the event loop isn't ready yet the event queues via try_user_callback_transition. (Could also be extended to connectionOptions.userActivities for universal links; this covers URLContexts — custom schemes + document opens.)

diff --git a/src/platform_impl/ios/app_state.rs b/src/platform_impl/ios/app_state.rs
@@ use crate::{
-    scene::multiple_scenes_enabled,
+    scene::{emit_opened_from_url_contexts, multiple_scenes_enabled},
@@ pub unsafe fn connect_scene(scene: &UIScene, options: &UISceneConnectionOptions)
     }
   }
+
+  // A cold-start URL/document open (the app was launched *by* it) is delivered
+  // here in `connectionOptions`, not via `scene:openURLContexts:` (which only
+  // fires while the app is already running). Without this, such opens are
+  // silently dropped under the scene lifecycle. Deliver them through the same
+  // `Event::Opened` path as the warm case; if the event loop isn't ready yet
+  // the event is queued (see `try_user_callback_transition`).
+  emit_opened_from_url_contexts(&options.URLContexts());
 }

diff --git a/src/platform_impl/ios/scene.rs b/src/platform_impl/ios/scene.rs
@@ pub unsafe fn multiple_scenes_enabled() -> bool {
   (*num).as_bool()
 }

+/// Parse the URLs out of a set of open-URL contexts and, if any, deliver them
+/// as a single `Event::Opened`. Shared by the two ways iOS surfaces a URL/
+/// document open to a scene-based app: `scene:openURLContexts:` (app already
+/// running) and the `connectionOptions` of `scene:willConnectToSession:`
+/// (app launched *by* the open — see `app_state::connect_scene`). Keeping
+/// both on this one path makes warm and cold opens symmetric.
+pub(crate) unsafe fn emit_opened_from_url_contexts(url_contexts: &NSSet<UIOpenURLContext>) {
+  let urls: Vec<url::Url> = url_contexts
+    .iter()
+    .filter_map(|ctx| {
+      ctx.URL().absoluteString().and_then(|url| {
+        let url = url.to_string();
+        url.parse().map_err(|e| {
+          log::error!("failed to parse URL {url} from open URL context: {e}");
+          e
+        }).ok()
+      })
+    })
+    .collect();
+  if !urls.is_empty() {
+    app_state::handle_nonuser_event(EventWrapper::StaticEvent(Event::Opened { urls }));
+  }
+}
+
 define_class!(
@@     #[unsafe(method(scene:openURLContexts:))]
     fn scene_openURLContexts(&self, _scene: &UIScene, url_contexts: &NSSet<UIOpenURLContext>) {
       unsafe {
-        let urls: Vec<url::Url> = url_contexts
-          .iter()
-          .filter_map(|ctx| { /* …same parsing… */ })
-          .collect();
-        if !urls.is_empty() {
-          app_state::handle_nonuser_event(EventWrapper::StaticEvent(Event::Opened { urls }));
-        }
+        emit_opened_from_url_contexts(url_contexts);
       }
     }

Verified on device (iPhone 15 Pro, tao 0.35.2): with this change a cold-start document open ("Open with") now fires Event::Opened exactly like the warm case.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions