diff --git a/android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt b/android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt index cfbb420aab..252b17bbc4 100644 --- a/android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt +++ b/android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt @@ -183,6 +183,7 @@ class LiquidTabsPlugin : Plugin() { if (tab.role == "search") { showSearchUi() } else { + clearSearchUi() hideSearchUi() } @@ -418,6 +419,11 @@ class LiquidTabsPlugin : Plugin() { container.visibility = View.VISIBLE } + private fun clearSearchUi() { + searchInput?.setText("") + resultsContainer?.removeAllViews() + } + private fun hideSearchUi() { searchContainer?.visibility = View.GONE } diff --git a/docs/develop-logseq-on-mobile.md b/docs/develop-logseq-on-mobile.md index e7f76c9d50..d2f0651c6a 100644 --- a/docs/develop-logseq-on-mobile.md +++ b/docs/develop-logseq-on-mobile.md @@ -22,7 +22,7 @@ - Install `ssl/mobile-dev/logseq-dev-ca.cer` on the iOS device and enable full trust for it in iOS Settings. - Working directory: Logseq root directory - Run `LOGSEQ_SHADOW_HTTPS=true pnpm mobile-watch` from the logseq project root directory in terminal. -- Run `LOGSEQ_APP_SERVER_URL=https://your-local-ip-address:3002 pnpm exec cap sync ios` in another terminal to copy web assets from public to *ios/App/App/public*, create *capacitor.config.json* in *ios/App/App*, and update iOS plugins. +- Run `LOGSEQ_APP_SERVER_URL=https://your-local-ip-address:3001/mobile pnpm exec cap sync ios` in another terminal to copy web assets from public to *ios/App/App/public*, create *capacitor.config.json* in *ios/App/App*, and update iOS plugins. - Connect your iOS device to MacBook. - Run `pnpm exec cap open ios` to open Logseq project in Xcode, and build the app there. @@ -60,15 +60,8 @@ or, you can run `bb release:ios-app` to do those steps with one command. ## Set up development environment ### Build the development app -- comment in `server` section in **capacitor.config.ts**, and replace `process.env.LOGSEQ_APP_ASERVER_URL` with your `http://your-local-ip-address:3001` (run `ifconfig` to check). - ```typescript - server: { - url: "process.env.LOGSEQ_APP_ASERVER_URL", - cleartext: true - } - ``` - Run `pnpm install && pnpm mobile-watch` from the logseq project root directory in terminal. -- Run `pnpm exec cap sync android` in another terminal. +- Run `LOGSEQ_APP_SERVER_URL=https://your-local-ip-address:3001/mobile pnpm exec cap sync android` in another terminal. - Run `pnpm exec cap run android` to install app into your device. or, you can run `bb dev:android-app` to do those steps with one command if you are on macOS. diff --git a/ios/App/App/LiquidTabsRootView.swift b/ios/App/App/LiquidTabsRootView.swift index 3ddf7fc3bc..c7a2d9a2a3 100644 --- a/ios/App/App/LiquidTabsRootView.swift +++ b/ios/App/App/LiquidTabsRootView.swift @@ -84,6 +84,94 @@ private extension UIApplication { } } +private struct TabReselectObserver: UIViewControllerRepresentable { + let selectedId: () -> String? + + func makeCoordinator() -> Coordinator { + Coordinator(selectedId: selectedId) + } + + func makeUIViewController(context: Context) -> UIViewController { + UIViewController() + } + + func updateUIViewController(_ viewController: UIViewController, context: Context) { + context.coordinator.selectedId = selectedId + + DispatchQueue.main.async { + context.coordinator.attach(from: viewController) + } + } + + final class Coordinator: NSObject, UITabBarControllerDelegate { + var selectedId: () -> String? + + private weak var tabBarController: UITabBarController? + private weak var previousDelegate: UITabBarControllerDelegate? + private var lastSelectedIndex: Int? + + init(selectedId: @escaping () -> String?) { + self.selectedId = selectedId + } + + deinit { + if let tabBarController, + tabBarController.delegate === self { + tabBarController.delegate = previousDelegate + } + } + + func attach(from viewController: UIViewController) { + guard let tabBarController = findTabBarController(from: viewController) else { + return + } + + if self.tabBarController === tabBarController, + tabBarController.delegate === self { + return + } + + previousDelegate = tabBarController.delegate + self.tabBarController = tabBarController + lastSelectedIndex = tabBarController.selectedIndex + tabBarController.delegate = self + } + + func tabBarController( + _ tabBarController: UITabBarController, + didSelect viewController: UIViewController + ) { + let selectedIndex = tabBarController.selectedIndex + + if selectedIndex == lastSelectedIndex, + let id = selectedId() { + LiquidTabsPlugin.shared?.notifyTabSelected(id: id, reselected: true) + } + + lastSelectedIndex = selectedIndex + previousDelegate?.tabBarController?(tabBarController, didSelect: viewController) + } + + private func findTabBarController(from viewController: UIViewController) -> UITabBarController? { + var current: UIViewController? = viewController + + while let viewController = current { + if let tabBarController = viewController as? UITabBarController { + return tabBarController + } + + current = viewController.parent + } + + if let tabBarController = viewController.tabBarController { + return tabBarController + } + + return nil + } + } +} + // MARK: - Root Tabs View (dispatch to 26+ vs 16–25) struct LiquidTabsRootView: View { @@ -297,6 +385,12 @@ private struct LiquidTabs26View: View { SearchFocusBridge(isActive: selectedTab == .search) .frame(width: 0, height: 0) } + .background { + TabReselectObserver(selectedId: { + store.tabId(for: selectedTab) + }) + .frame(width: 0, height: 0) + } .overlay { if store.pendingWebTabId != nil { Color.logseqBackground @@ -842,6 +936,12 @@ private struct LiquidTabs16View: View { searchPath = NavigationPath() } } + .background { + TabReselectObserver(selectedId: { + store.selectedId ?? store.firstTab?.id + }) + .frame(width: 0, height: 0) + } } .onAppear { if store.selectedId == nil { diff --git a/scripts/src/logseq/tasks/dev/mobile.clj b/scripts/src/logseq/tasks/dev/mobile.clj index 18b7c13664..dcecf55182 100644 --- a/scripts/src/logseq/tasks/dev/mobile.clj +++ b/scripts/src/logseq/tasks/dev/mobile.clj @@ -3,10 +3,12 @@ (:require [babashka.fs :as fs] [babashka.tasks :refer [shell]] [clojure.string :as string] - [logseq.tasks.util :as task-util])) + [logseq.tasks.util :as task-util]) + (:import [java.nio.file FileAlreadyExistsException])) -(def ^:private dev-server-port "3002") +(def ^:private dev-server-port "3001") (def ^:private ssl-dir "ssl/mobile-dev") +(def ^:private ssl-lock-file "ssl/mobile-dev/.cert.lock") (def ^:private ssl-ca-keystore "ssl/mobile-dev/ca-keystore.jks") (def ^:private ssl-ca-cert "ssl/mobile-dev/logseq-dev-ca.cer") (def ^:private ssl-keystore "ssl/mobile-dev/keystore.jks") @@ -16,12 +18,42 @@ (defn- local-ip [] - (string/trim (:out (or (shell {:out :string :continue true :shutdown nil} "ipconfig getifaddr en0") - (shell {:out :string :shutdown nil} "ipconfig getifaddr en1"))))) + (letfn [(interface-ip [interface] + (let [{:keys [exit out]} (shell {:out :string + :err :string + :continue true + :shutdown nil} + (str "ipconfig getifaddr " interface)) + ip (string/trim (or out ""))] + (when (and (zero? exit) (not (string/blank? ip))) + ip)))] + (or (interface-ip "en0") + (interface-ip "en1") + (throw (ex-info "Failed to detect local network IP" {}))))) (defn- dev-server-url [] - (format "https://%s:%s" (local-ip) dev-server-port)) + (format "https://%s:%s/mobile" (local-ip) dev-server-port)) + +(defn- with-ssl-cert-lock! + [f] + (fs/create-dirs ssl-dir) + (loop [attempt 0] + (let [locked? (try + (fs/create-file ssl-lock-file) + true + (catch FileAlreadyExistsException _ + false))] + (when-not locked? + (when (> attempt 600) + (throw (ex-info "Timed out waiting for mobile HTTPS certificate lock" + {:lock-file ssl-lock-file}))) + (Thread/sleep 100) + (recur (inc attempt))))) + (try + (f) + (finally + (fs/delete-if-exists ssl-lock-file)))) (defn- usable-keystore? [] @@ -121,14 +153,15 @@ (defn ensure-dev-ssl-cert! "Creates the local Shadow CLJS dev HTTPS keystore and exported iOS trust CA." [] - (let [ip (local-ip)] - (fs/create-dirs ssl-dir) - (backup-existing-ssl-files!) - (when-not (usable-keystore?) - (generate-dev-ssl-cert! ip)) - (println "Shadow CLJS HTTPS keystore:" ssl-keystore) - (println "Install and fully trust this CA certificate on iOS:" ssl-ca-cert) - (println "iOS dev server URL:" (dev-server-url)))) + (with-ssl-cert-lock! + (fn [] + (let [ip (local-ip)] + (backup-existing-ssl-files!) + (when-not (usable-keystore?) + (generate-dev-ssl-cert! ip)) + (println "Shadow CLJS HTTPS keystore:" ssl-keystore) + (println "Install and fully trust this CA certificate on iOS:" ssl-ca-cert) + (println "Mobile dev server URL:" (dev-server-url)))))) (defn ensure-dev-ssl-cert-task [] @@ -172,7 +205,9 @@ (defn cap-run-android "Copy assets files to Android build directory, and run app in Android Studio" [] - (open-dev-app {} "pnpm exec cap sync android") + (ensure-dev-ssl-cert!) + (open-dev-app {:extra-env {"LOGSEQ_APP_SERVER_URL" (dev-server-url)}} + "pnpm exec cap sync android") (shell {:shutdown nil} "pnpm exec cap open android")) (defn run-ios-release diff --git a/shadow-cljs.edn b/shadow-cljs.edn index b0ec1c60be..3852565a89 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -8,8 +8,7 @@ :password "shadow-cljs"} ;; "." for /static - :dev-http {3001 ["static" "."] - 3002 "static/mobile"} + :dev-http {3001 ["static" "."]} :js-options {:js-package-dirs ["node_modules"]} diff --git a/src/main/mobile/state.cljs b/src/main/mobile/state.cljs index 3d9cbaf85f..55aae48a96 100644 --- a/src/main/mobile/state.cljs +++ b/src/main/mobile/state.cljs @@ -10,8 +10,8 @@ (defn set-tab! [tab] (let [prev @*tab] ;; When leaving the search tab, clear its stack so reopening starts fresh. - ;; The query/results are cleared by native when Search is opened again; doing - ;; it here makes the Search UI redraw during the Home tab transition. + ;; Native search UI owns query/result clearing on each platform; doing it here + ;; makes the Search UI redraw during the Home tab transition on iOS. (when (and (= prev "search") (not= tab "search")) (mobile-nav/reset-stack-history! "search"))