diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index 0085166489..9e41a01d67 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -8,15 +8,70 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel var window: UIWindow? var navController: UINavigationController? + + // --------------------------------------------------------- + // MARK: Multi-stack routing state + // --------------------------------------------------------- + + /// Currently active logical stack id (must match CLJS :stack, e.g. "home", "capture", "goto"). + private var activeStackId: String = "home" + + /// Per-stack path stacks, including the active one. + /// Example: ["home": ["/", "/page/A"], "capture": ["/__stack__/capture"]] + private var stackPathStacks: [String: [String]] = [ + "home": ["/"] + ] + + /// Mirror of the active stack's paths. private var pathStack: [String] = ["/"] - private var ignoreRoutePopCount = 0 + + /// Used to ignore JS-driven pops when we're popping in response to a native gesture. + private var ignoreRoutePopCount: Int = 0 + + /// Temporary snapshot image for smooth pop transitions. private var popSnapshotView: UIView? + // --------------------------------------------------------- + // MARK: Helpers + // --------------------------------------------------------- + private func normalizedPath(_ raw: String?) -> String { guard let raw = raw, !raw.isEmpty else { return "/" } return raw } + private func debugLogStacks(_ label: String) { + #if DEBUG + print("🧭 [\(label)] activeStackId=\(activeStackId)") + print(" pathStack=\(pathStack)") + print(" stackPathStacks=\(stackPathStacks)") + #endif + } + + /// Returns the current native path stack for a given logical stack id, + /// or initialises a sensible default if none exists yet. + private func paths(for stackId: String) -> [String] { + if let existing = stackPathStacks[stackId], !existing.isEmpty { + return existing + } + + if stackId == "home" { + return ["/"] + } else { + // Virtual stacks (e.g. capture, search, goto) default to a stack-root path. + return ["/__stack__/\(stackId)"] + } + } + + /// Updates the stored paths for a given stack id and keeps `pathStack` + /// consistent if this is the active stack. + private func setPaths(_ paths: [String], for stackId: String) { + stackPathStacks[stackId] = paths + if stackId == activeStackId { + pathStack = paths + } + } + // --------------------------------------------------------- // MARK: UIApplication lifecycle // --------------------------------------------------------- @@ -124,7 +179,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel } // --------------------------------------------------------- - // MARK: Navigation operations + // MARK: Navigation operations (within active stack) // --------------------------------------------------------- private func emptyNavStack(path: String) { @@ -137,10 +192,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel let vc = NativePageViewController(path: path, push: false) pathStack = [path] + setPaths(pathStack, for: activeStackId) nav.setViewControllers([vc], animated: false) SharedWebViewController.instance.clearPlaceholder() SharedWebViewController.instance.attach(to: vc) + + debugLogStacks("emptyNavStack") } private func pushIfNeeded(path: String, animated: Bool) { @@ -154,7 +212,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel let vc = NativePageViewController(path: path, push: true) pathStack.append(path) + setPaths(pathStack, for: activeStackId) + nav.pushViewController(vc, animated: animated) + + debugLogStacks("pushIfNeeded") } private func replaceTop(path: String) { @@ -162,9 +224,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel guard let nav = navController else { return } _ = pathStack.popLast() - let vc = NativePageViewController(path: path, push: false) pathStack.append(path) + setPaths(pathStack, for: activeStackId) + let vc = NativePageViewController(path: path, push: false) var stack = nav.viewControllers if stack.isEmpty { stack = [vc] @@ -172,6 +235,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel stack[stack.count - 1] = vc } nav.setViewControllers(stack, animated: false) + + debugLogStacks("replaceTop") } private func popIfNeeded(animated: Bool) { @@ -179,7 +244,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel if nav.viewControllers.count > 1 { _ = pathStack.popLast() + setPaths(pathStack, for: activeStackId) nav.popViewController(animated: animated) + + debugLogStacks("popIfNeeded") } } @@ -206,15 +274,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel vcs.count < pathStack.count } + #if DEBUG + print("🧭 willShow — isPop=\(isPop)") + print(" toVC=\(toVC.targetPath) fromVC=\(String(describing: fromVC?.targetPath))") + debugLogStacks("willShow") + #endif + if isPop { // ----------------------------- - // POP — keep your existing logic + // POP — update per-stack pathStack, then notify JS. // ----------------------------- let previousStack = pathStack - if pathStack.count > 1 { _ = pathStack.popLast() } + + if pathStack.count > 1 { + _ = pathStack.popLast() + } if let last = pathStack.last, last != toVC.targetPath { pathStack[pathStack.count - 1] = toVC.targetPath } + setPaths(pathStack, for: activeStackId) popSnapshotView?.removeFromSuperview() popSnapshotView = nil @@ -227,9 +305,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel popSnapshotView = iv } - coordinator.animate(alongsideTransition: nil) { ctx in + coordinator.animate(alongsideTransition: nil) { [weak self] ctx in + guard let self else { return } + guard !ctx.isCancelled else { self.pathStack = previousStack + self.setPaths(previousStack, for: self.activeStackId) + if let fromVC { SharedWebViewController.instance.attach(to: fromVC) } @@ -237,12 +319,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel return } - if let webView = SharedWebViewController.instance.bridgeController.bridge?.webView, - webView.canGoBack { - self.ignoreRoutePopCount += 1 - webView.goBack() - } else { - self.ignoreRoutePopCount += 1 + // 🔑 DO NOT call webView.goBack(). + // Tell JS explicitly that native popped. + self.ignoreRoutePopCount += 1 + #if DEBUG + print("⬅️ Native POP completed, notifying JS via onNativePop(), ignoreRoutePopCount=\(self.ignoreRoutePopCount)") + debugLogStacks("after native-pop pathStack update") + #endif + + if let bridge = SharedWebViewController.instance.bridgeController.bridge { + let js = "window.LogseqNative && window.LogseqNative.onNativePop && window.LogseqNative.onNativePop();" + bridge.webView?.evaluateJavaScript(js, completionHandler: nil) } SharedWebViewController.instance.attach( @@ -264,8 +351,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel // ----------------------------- // PUSH / RESET // ----------------------------- - // Attach the shared webview to the *destination* page - // before/during the animation so it can start rendering immediately. SharedWebViewController.instance.attach( to: toVC, leavePlaceholderInPreviousParent: fromVC != nil @@ -273,10 +358,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel coordinator.animate(alongsideTransition: nil) { ctx in if ctx.isCancelled, let fromVC { - // If the push is cancelled (interactive back), put the webview back. SharedWebViewController.instance.attach(to: fromVC) } else { - // Transition completed → clear any placeholders. SharedWebViewController.instance.clearPlaceholder() } } @@ -294,7 +377,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel SharedWebViewController.instance.clearPlaceholder() SharedWebViewController.instance.attach(to: current) } - } func navigationController( @@ -308,7 +390,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel } // --------------------------------------------------------- - // MARK: Route Observation + // MARK: Route Observation (JS -> Native) // --------------------------------------------------------- private func observeRouteChanges() { @@ -318,10 +400,69 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel queue: .main ) { [weak self] notification in guard let self else { return } + guard let nav = self.navController else { return } - let path = self.normalizedPath(notification.userInfo?["path"] as? String) + let rawPath = notification.userInfo?["path"] as? String + let path = self.normalizedPath(rawPath) let navigationType = (notification.userInfo?["navigationType"] as? String) ?? "push" + let stackId = (notification.userInfo?["stack"] as? String) ?? "home" + #if DEBUG + print("📡 routeDidChange from JS → native") + print(" stackId=\(stackId) navigationType=\(navigationType) path=\(path)") + debugLogStacks("before observeRouteChanges") + #endif + + // ============================================ + // 1️⃣ Stack switch: home ↔ capture ↔ goto ... + // ============================================ + if stackId != self.activeStackId { + // Save current native stack paths + self.setPaths(self.pathStack, for: self.activeStackId) + + // Load (or create) new stack's paths + var newPaths = self.paths(for: stackId) + + // Ensure the top of the stack matches the path sent by JS + if let last = newPaths.last, last != path { + if newPaths.isEmpty { + newPaths = [path] + } else { + newPaths[newPaths.count - 1] = path + } + } + + self.activeStackId = stackId + self.pathStack = newPaths + self.setPaths(newPaths, for: stackId) + + // Rebuild the UINavigationController's stack from these paths + var vcs: [UIViewController] = [] + for (idx, p) in newPaths.enumerated() { + let vc = NativePageViewController(path: p, push: idx > 0) + vcs.append(vc) + } + + nav.setViewControllers(vcs, animated: false) + + if let lastVC = vcs.last as? NativePageViewController { + SharedWebViewController.instance.attach(to: lastVC) + SharedWebViewController.instance.clearPlaceholder() + } + + #if DEBUG + print("🔀 STACK SWITCH to \(stackId)") + debugLogStacks("after stack switch") + #endif + + // For stacks like "capture", default paths are ["__/stack__/capture"], + // so they get a single VC and no back button. + return + } + + // ============================================ + // 2️⃣ Navigation *within* the active stack + // ============================================ switch navigationType { case "reset": self.emptyNavStack(path: path) @@ -332,9 +473,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel case "pop": if self.ignoreRoutePopCount > 0 { self.ignoreRoutePopCount -= 1 + #if DEBUG + print("🙈 ignoring JS pop (ignoreRoutePopCount→\(self.ignoreRoutePopCount))") + debugLogStacks("after ignore JS pop") + #endif return } - if self.pathStack.count > 1 { self.popIfNeeded(animated: true) } @@ -342,6 +486,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel default: self.pushIfNeeded(path: path, animated: true) } + + #if DEBUG + debugLogStacks("after observeRouteChanges switch") + #endif } } } @@ -372,6 +520,10 @@ extension NSUserActivity { } } +// --------------------------------------------------------- +// MARK: Convenience +// --------------------------------------------------------- + extension AppDelegate { func donateQuickAddShortcut() { let a = NSUserActivity.quickAdd diff --git a/ios/App/App/UILocalPlugin.swift b/ios/App/App/UILocalPlugin.swift index f8df61047c..9fe133bfab 100644 --- a/ios/App/App/UILocalPlugin.swift +++ b/ios/App/App/UILocalPlugin.swift @@ -644,26 +644,30 @@ private func scoreTranscript(_ text: String, locale: Locale) -> Int { } @objc func routeDidChange(_ call: CAPPluginCall) { - let route = call.getObject("route") as? [String: Any] - let path = call.getString("path") - let push = call.getBool("push") ?? true - let navigationType = call.getString("navigationType") ?? (push ? "push" : "replace") + let navigationType = call.getString("navigationType") ?? "push" + let push = call.getBool("push") ?? (navigationType == "push") + let path = call.getString("path") ?? "/" - var entry: [String: Any] = [:] - if let path = path { - entry["path"] = path - } - if let route = route { - entry["route"] = route - } - entry["push"] = push - entry["navigationType"] = navigationType + // ✅ read stack from JS, default to "home" only if missing + let stack = call.getString("stack") ?? "home" - NotificationCenter.default.post( - name: UILocalPlugin.routeChangeNotification, - object: nil, - userInfo: entry - ) + #if DEBUG + print("📬 UILocal.routeDidChange call from JS") + print(" navigationType=\(navigationType) push=\(push) stack=\(stack) path=\(path)") + #endif + + DispatchQueue.main.async { + NotificationCenter.default.post( + name: UILocalPlugin.routeChangeNotification, + object: nil, + userInfo: [ + "navigationType": navigationType, + "push": push, + "stack": stack, // 👈 forward it + "path": path + ] + ) + } call.resolve() } diff --git a/src/main/mobile/bottom_tabs.cljs b/src/main/mobile/bottom_tabs.cljs index 3d3cbe688f..e78d5f512e 100644 --- a/src/main/mobile/bottom_tabs.cljs +++ b/src/main/mobile/bottom_tabs.cljs @@ -98,8 +98,9 @@ (js/console.log "Native search query" q) (reset! mobile-state/*search-input q) (reset! mobile-state/*search-last-input-at (common-util/time-ms)) - (when (= :page (state/get-current-route)) - (mobile-nav/reset-route!)))) + (comment + (when (= :page (state/get-current-route)) + (mobile-nav/reset-route!))))) (add-keyboard-hack-listener!))) (defn configure diff --git a/src/main/mobile/core.cljs b/src/main/mobile/core.cljs index d4ff2cf170..d4f23e80e8 100644 --- a/src/main/mobile/core.cljs +++ b/src/main/mobile/core.cljs @@ -90,6 +90,7 @@ ;; so it is available even in :advanced release builds (prn "[Mobile] init!") (log/add-handler mobile-state/log-append!) + (mobile-nav/install-native-bridge!) (set-router!) (init/init!) (fhandler/start! render!)) diff --git a/src/main/mobile/navigation.cljs b/src/main/mobile/navigation.cljs index 154e79d7e8..72a0791aee 100644 --- a/src/main/mobile/navigation.cljs +++ b/src/main/mobile/navigation.cljs @@ -7,6 +7,7 @@ [promesa.core :as p] [reitit.frontend.easy :as rfe])) +;; Each tab owns a navigation stack (defonce navigation-source (atom nil)) (defonce ^:private initialised-stacks (atom {})) (def ^:private primary-stack "home") @@ -15,8 +16,30 @@ (defonce ^:private pending-navigation (atom nil)) (defonce ^:private hooks-installed? (atom false)) +;; --- DEBUG toggle --- +(def ^:private debug-nav? true) + +(defn- dbg [tag & args] + (when debug-nav? + (let [payload (cond + ;; one map argument → use it directly + (and (= 1 (count args)) + (map? (first args))) + (first args) + + ;; even number of args → treat as k/v pairs + (even? (count args)) + (apply hash-map args) + + ;; odd / weird → just log the raw args + :else + {:args args})] + (log/info tag payload)))) + ;; Track whether the latest change came from a native back gesture / popstate. -(.addEventListener js/window "popstate" (fn [_] (reset! navigation-source :pop))) +(.addEventListener js/window "popstate" (fn [_] + (reset! navigation-source :pop) + (dbg :nav/popstate {:source :popstate}))) (defn current-stack [] @@ -25,6 +48,7 @@ (defn set-current-stack! [stack] (when (some? stack) + (dbg :nav/set-current-stack {:from @active-stack :to stack}) (reset! active-stack stack))) (defn- strip-fragment [href] @@ -38,10 +62,6 @@ (let [p (strip-fragment (.-hash js/location))] (if (string/blank? p) "/" p))) -(defn- virtual-path? - [path] - (and (string? path) (string/starts-with? path "/__stack__/"))) - (defn- stack-defaults [stack] (let [name (keyword stack) @@ -57,6 +77,7 @@ (defn- record-navigation-intent! [{:keys [type stack]}] (let [stack (or stack @active-stack primary-stack)] + (dbg :nav/record-intent {:type type :stack stack}) (reset! pending-navigation {:type type :stack stack}))) @@ -70,6 +91,7 @@ ([k params query] (record-navigation-intent! {:type :push :stack @active-stack}) + (dbg :nav/push-state {:name k :params params :query query :stack @active-stack}) (orig-push-state k params query))) (defonce orig-replace-state rfe/replace-state) @@ -81,6 +103,7 @@ ([k params query] (record-navigation-intent! {:type :replace :stack @active-stack}) + (dbg :nav/replace-state {:name k :params params :query query :stack @active-stack}) (orig-replace-state k params query))) (defn install-navigation-hooks! @@ -88,6 +111,7 @@ Also tags navigation with the active stack so native can keep per-stack history." [] (when (compare-and-set! hooks-installed? false true) + (dbg :nav/hooks-installed {}) (set! rfe/push-state push-state) (set! rfe/replace-state replace-state))) @@ -108,6 +132,14 @@ [stack] (-> @stack-history (get stack) :history last)) +;; --- DEBUG: watch stack-history changes --- +(add-watch stack-history ::stack-history-debug + (fn [_ _ old new] + (when debug-nav? + (dbg :nav/stack-history + :old (into {} (for [[k v] old] [k (mapv :path (:history v))])) + :new (into {} (for [[k v] new] [k (mapv :path (:history v))])))))) + (defn- remember-route! [stack nav-type route path route-match] (when stack @@ -128,6 +160,12 @@ (conj history entry)) history)))] (when entry + (dbg :nav/remember-route + :stack stack + :nav-type nav-type + :path path + :route-to (or (get-in route [:to]) + (get-in route-match [:data :name]))) (swap! stack-history update stack (fn [{:keys [history] :as st}] {:history (update-history history)})) (swap! initialised-stacks assoc stack true))))) @@ -135,6 +173,7 @@ (defn reset-stack-history! [stack] (when stack + (dbg :nav/reset-stack-history {:stack stack}) (swap! stack-history assoc stack {:history [(stack-defaults stack)]}) (swap! initialised-stacks dissoc stack))) @@ -154,6 +193,12 @@ (true? push) "push" :else "push"))] (reset! navigation-source nil) + (dbg :nav/next-navigation + :src src + :intent intent + :stack stack + :first? first? + :nav-type nav-type) (when first? (swap! initialised-stacks assoc stack true)) {:navigation-type nav-type @@ -162,6 +207,7 @@ (defn- notify-route-payload! [payload] + (dbg :nav/notify-native payload) (-> (.routeDidChange mobile-util/ui-local (clj->js payload)) (p/catch (fn [err] (log/warn :mobile-native-navigation/route-report-failed @@ -169,17 +215,19 @@ :payload payload}))))) (defn notify-route-change! - "Inform native iOS layer of a route change to keep native stack in sync. - {route {to keyword, path-params map, query-params map} - route-match map ;; optional full route match for fast restoration - path string ;; optional, e.g. \"/page/Today\" - push boolean? ;; optional, explicit push vs replace hint}" + "Inform native iOS layer of a route change to keep native stack in sync." [{:keys [route route-match path push stack]}] (let [{:keys [navigation-type push? stack]} (next-navigation! {:push push :nav-type (:navigation-type route-match) :stack (or stack (current-stack))}) stack (or stack (current-stack)) path (or path (current-path))] + (dbg :nav/notify-route-change + :stack stack + :navigation-type navigation-type + :path path + :route-to (or (:to route) + (get-in route-match [:data :name]))) (set-current-stack! stack) (remember-route! stack navigation-type route path route-match) (when (and (mobile-util/native-ios?) @@ -191,27 +239,86 @@ path (assoc :path (strip-fragment path)))] (notify-route-payload! payload))))) -(defn reset-route! - [] - (route-handler/redirect-to-home! false) - (let [stack (current-stack)] - (reset-stack-history! stack) - (notify-route-payload! - {:navigationType "reset" - :push false - :stack stack}))) +(comment + (defn reset-route! + [] + (route-handler/redirect-to-home! false) + (let [stack (current-stack)] + (reset-stack-history! stack) + (notify-route-payload! + {:navigationType "reset" + :push false + :stack stack})))) (defn switch-stack! - "Activate a stack and restore its last known route if different from current location." + "Activate a stack and restore its last known route." [stack] (when stack (let [stack (ensure-stack stack)] + (dbg :nav/switch-stack {:to stack + :current @active-stack + :stack-top (select-keys (stack-top stack) [:path])}) (set-current-stack! stack) (when-let [{:keys [path route route-match]} (stack-top stack)] (let [route-match (or route-match (:route-match (stack-defaults stack))) - path (or path (current-path))] - ;; Update local route state immediately for UI (header, page context) without full router churn. + path (or path (current-path))] + (dbg :nav/switch-stack-apply + :stack stack + :path path + :route-name (or (get-in route [:data :name]) + (get-in route-match [:data :name]))) (route-handler/set-route-match! route-match) - ;; Avoid triggering native navigation on stack switches; we rely on per-stack - ;; history and UI updates handled in JS for snappy tab changes. - ))))) + (notify-route-change! + {:route {:to (or (get-in route [:data :name]) + (get-in route-match [:data :name])) + :path-params (or (:path-params route) + (get-in route-match [:parameters :path])) + :query-params (or (:query-params route) + (get-in route-match [:parameters :query]))} + :path path + :stack stack + :push false})))))) + +(defn pop-stack! + "Pop one route from the current stack, update router via replace-state. + Called when native UINavigationController pops (back gesture / back button)." + [] + (let [stack (current-stack) + {:keys [history]} (get @stack-history stack) + history (vec history)] + (if (<= (count history) 1) + (dbg :nav/pop-stack-root {:stack stack + :history (mapv :path history)}) + (let [new-history (subvec history 0 (dec (count history))) + {:keys [route route-match path]} (peek new-history) + route-match (or route-match (:route-match (stack-defaults stack))) + route-name (get-in route-match [:data :name]) + path-params (get-in route-match [:parameters :path]) + query-params (get-in route-match [:parameters :query])] + + (dbg :nav/pop-stack + :stack stack + :old-history (mapv :path history) + :new-history (mapv :path new-history) + :target-path path + :route-name route-name) + + (swap! stack-history assoc stack {:history new-history}) + + ;; Pretend this came from a pop for next-navigation! + (reset! navigation-source :pop) + + ;; Use *original* replace-state to avoid recording a :replace intent. + (orig-replace-state route-name path-params query-params) + + (route-handler/set-route-match! route-match))))) + +(defn ^:export install-native-bridge! + [] + (dbg :nav/install-native-bridge {}) + (set! (.-LogseqNative js/window) + (clj->js + {:onNativePop (fn [] + (dbg :nav/on-native-pop {:stack (current-stack) + :path (current-path)}) + (pop-stack!))})))