experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ios): native inline WKWebView + fix dev build pipeline

Add native WKWebView as subview (replaces iframe) for inline URL browsing.
Sites like YouTube/Reddit that block iframes now load with Safari User-Agent.

Swift FFI: webview_plugin_open_inline(url, topOffset) positions WKWebView
at the card's measured Y offset; webview_plugin_close_inline() removes it.

Fix dev build pipeline:
- custom-protocol feature required for iOS even in dev (serves bootstrap HTML)
- Bootstrap HTML in gen/apple/assets replaces bundled app with localhost redirect
- dev-ios-sim.sh starts vite BEFORE app launch (fixes race condition)
- build-ios.sh patches bootstrap with DEV_PORT before cargo build

Reduce webview open animation from 0.3s to 0.2s.

+232 -39
+1 -1
backend/tauri-mobile/BUILD_NUMBER
··· 1 - 1052 1 + 1061
+19 -9
backend/tauri-mobile/build-ios.sh
··· 51 51 touch src/lib.rs 52 52 fi 53 53 54 - # Build frontend first (normally done by cargo tauri build's beforeBuildCommand) 54 + # Build frontend first (for type checking), then replace with bootstrap for dev mode 55 55 echo "Building frontend..." 56 56 cd "$SCRIPT_DIR" 57 57 npm run build 58 58 cd "$TAURI_DIR" 59 59 60 + # Replace dist/index.html with bootstrap redirect BEFORE cargo build 61 + # so Tauri's custom-protocol embeds the redirect, not the real app. 62 + # The actual app will be served by the vite HMR dev server. 63 + # DEV_PORT env var is set by dev-ios-sim.sh with the actual port. 64 + echo "Creating bootstrap HTML for dev server mode..." 65 + cp "$SCRIPT_DIR/dev-bootstrap.html" "$SCRIPT_DIR/dist/index.html" 66 + mkdir -p "$TAURI_DIR/gen/apple/assets" 67 + cp "$SCRIPT_DIR/dev-bootstrap.html" "$TAURI_DIR/gen/apple/assets/index.html" 68 + if [ -n "$DEV_PORT" ]; then 69 + sed -i.bak "s/__DEV_PORT__/$DEV_PORT/g" "$SCRIPT_DIR/dist/index.html" 70 + rm -f "$SCRIPT_DIR/dist/index.html.bak" 71 + sed -i.bak "s/__DEV_PORT__/$DEV_PORT/g" "$TAURI_DIR/gen/apple/assets/index.html" 72 + rm -f "$TAURI_DIR/gen/apple/assets/index.html.bak" 73 + fi 74 + 60 75 echo "Building Rust for iOS simulator (debug)..." 61 76 # Match the Xcode project's deployment target so cc-compiled objects 62 77 # don't emit "built for newer iOS-simulator" linker warnings 63 78 export IPHONEOS_DEPLOYMENT_TARGET=16.0 64 79 # Build only the lib target - the bin target would fail to link because 65 80 # Swift FFI symbols (webview_plugin_*) are provided by Xcode at final link time 66 - cargo build --lib --target aarch64-apple-ios-sim 81 + # custom-protocol is required so Tauri serves bundled assets via scheme handler; 82 + # for dev builds the "assets" are a bootstrap HTML that redirects to the dev server 83 + cargo build --lib --target aarch64-apple-ios-sim --features custom-protocol 67 84 68 85 # Copy to Xcode location 69 86 mkdir -p "$DEST_DIR" 70 87 cp target/aarch64-apple-ios-sim/debug/libpeek_save_lib.a "$DEST_PATH" 71 - 72 - # For debug builds with devUrl, create minimal bootstrap that redirects to dev server 73 - # Release builds (build-release.sh) will populate this with bundled assets 74 - echo "Creating bootstrap HTML for dev server mode..." 75 - mkdir -p "$TAURI_DIR/gen/apple/assets" 76 - # Copy bootstrap HTML (will be updated with actual port by dev-ios-sim.sh) 77 - cp "$SCRIPT_DIR/dev-bootstrap.html" "$TAURI_DIR/gen/apple/assets/index.html" 78 88 79 89 # Update cache 80 90 if [ "$NO_CACHE" = false ]; then
+1
backend/tauri-mobile/dev-bootstrap.html
··· 7 7 <script> 8 8 // Redirect to dev server on localhost 9 9 // iOS simulator can access Mac's localhost 10 + // Vite is guaranteed running before app launch (dev-ios-sim.sh waits for it) 10 11 window.location.href = 'http://localhost:__DEV_PORT__/'; 11 12 </script> 12 13 </head>
+30 -8
backend/tauri-mobile/dev-ios-sim.sh
··· 21 21 # Save original tauri.conf.json for restoration 22 22 cp "$CONF" "$CONF.bak" 23 23 24 + VITE_PID="" 24 25 cleanup() { 25 26 echo "" 27 + if [ -n "$VITE_PID" ] && kill -0 "$VITE_PID" 2>/dev/null; then 28 + echo "[dev] Stopping vite server..." 29 + kill "$VITE_PID" 2>/dev/null 30 + wait "$VITE_PID" 2>/dev/null 31 + fi 26 32 echo "[dev] Restoring tauri.conf.json..." 27 33 mv "$CONF.bak" "$CONF" 28 34 echo "[dev] Done." ··· 49 55 50 56 # Rebuild Rust library (picks up devUrl from tauri.conf.json) 51 57 # Uses --force to ensure config change is picked up 58 + # DEV_PORT tells build-ios.sh to patch the bootstrap HTML with the actual port 52 59 echo "[dev] Building Rust library with devUrl..." 53 - cd "$SCRIPT_DIR" && ./build-ios.sh --force 54 - 55 - # Patch bootstrap HTML with actual port 56 - sed -i.bak "s/__DEV_PORT__/$PORT/g" "$SCRIPT_DIR/src-tauri/gen/apple/assets/index.html" 57 - rm -f "$SCRIPT_DIR/src-tauri/gen/apple/assets/index.html.bak" 60 + cd "$SCRIPT_DIR" && DEV_PORT=$PORT ./build-ios.sh --force 58 61 59 62 # Build with xcodebuild 60 63 echo "[dev] Running xcodebuild..." ··· 67 70 -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ 68 71 clean build 2>&1 | tail -5 69 72 73 + # Start vite dev server in background BEFORE launching app (avoids race condition) 74 + echo "[dev] Starting HMR dev server on port $PORT..." 75 + cd "$SCRIPT_DIR" && npx vite --host 0.0.0.0 --port $PORT & 76 + VITE_PID=$! 77 + 78 + # Wait for vite to be ready 79 + echo "[dev] Waiting for vite server to be ready..." 80 + for i in $(seq 1 30); do 81 + if curl -s -o /dev/null "http://localhost:$PORT/" 2>/dev/null; then 82 + echo "[dev] Vite server ready" 83 + break 84 + fi 85 + if [ "$i" -eq 30 ]; then 86 + echo "[dev] ERROR: Vite server did not start within 15 seconds" 87 + exit 1 88 + fi 89 + sleep 0.5 90 + done 91 + 70 92 # Install and launch 71 93 echo "[dev] Installing to simulator..." 72 94 xcrun simctl install booted '/tmp/peek-xcodebuild/Build/Products/debug-iphonesimulator/Peek Save.app' 73 95 xcrun simctl terminate booted com.dietrich.peek-mobile 2>/dev/null || true 74 96 xcrun simctl launch booted com.dietrich.peek-mobile 75 - echo "[dev] App launched, starting HMR dev server on port $PORT..." 97 + echo "[dev] App launched. HMR active — Ctrl+C to stop." 76 98 77 - # Start vite dev server (blocks — HMR active until Ctrl+C) 78 - cd "$SCRIPT_DIR" && DEV_PORT=$PORT npx vite --host 0.0.0.0 --port $PORT 99 + # Wait for vite to exit (blocks until Ctrl+C) 100 + wait $VITE_PID
+5 -1
backend/tauri-mobile/src-tauri/capabilities/default.json
··· 5 5 "windows": ["main"], 6 6 "permissions": [ 7 7 "core:default", 8 - "opener:default" 8 + "opener:default", 9 + "core:webview:default", 10 + "core:webview:allow-create-webview", 11 + "core:webview:allow-create-webview-window", 12 + "core:window:default" 9 13 ] 10 14 }
+92
backend/tauri-mobile/src-tauri/gen/apple/Sources/peek-save/WebviewPlugin.swift
··· 97 97 } 98 98 } 99 99 100 + // Navigation delegate for inline webview (must be held strongly) 101 + class InlineWebViewDelegate: NSObject, WKNavigationDelegate { 102 + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 103 + print("[WebviewPlugin] Inline webview loaded: \(webView.url?.absoluteString ?? "nil")") 104 + } 105 + 106 + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { 107 + print("[WebviewPlugin] Inline webview navigation failed: \(error.localizedDescription)") 108 + } 109 + 110 + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { 111 + print("[WebviewPlugin] Inline webview provisional load failed: \(error.localizedDescription)") 112 + } 113 + 114 + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { 115 + decisionHandler(.allow) 116 + } 117 + } 118 + 100 119 // Global state 101 120 private var currentWebViewController: EmbeddedWebViewController? 121 + private var currentInlineWebView: WKWebView? 122 + private var inlineWebViewDelegate: InlineWebViewDelegate? 102 123 103 124 // C-compatible function to open webview - called from Rust FFI 104 125 @_cdecl("webview_plugin_open") ··· 157 178 currentWebViewController = nil 158 179 } 159 180 } 181 + 182 + // C-compatible function to open inline webview (subview, not modal) 183 + @_cdecl("webview_plugin_open_inline") 184 + public func webviewPluginOpenInline(urlPtr: UnsafePointer<CChar>, topOffset: Double) -> Bool { 185 + let urlString = String(cString: urlPtr) 186 + print("[WebviewPlugin] open_inline called with URL: \(urlString), topOffset: \(topOffset)") 187 + 188 + guard let url = URL(string: urlString) else { 189 + print("[WebviewPlugin] Invalid URL: \(urlString)") 190 + return false 191 + } 192 + 193 + DispatchQueue.main.async { 194 + // Remove any existing inline webview 195 + currentInlineWebView?.removeFromSuperview() 196 + currentInlineWebView = nil 197 + 198 + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, 199 + let window = windowScene.windows.first, 200 + let rootView = window.rootViewController?.view else { 201 + print("[WebviewPlugin] ERROR: Could not find root view") 202 + return 203 + } 204 + 205 + // Create WKWebView with Safari-like config 206 + let config = WKWebViewConfiguration() 207 + config.allowsInlineMediaPlayback = true 208 + config.mediaTypesRequiringUserActionForPlayback = [] 209 + 210 + let webView = WKWebView(frame: .zero, configuration: config) 211 + webView.translatesAutoresizingMaskIntoConstraints = false 212 + webView.allowsBackForwardNavigationGestures = true 213 + 214 + // Set Safari-like user agent so sites don't reject us 215 + webView.customUserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1" 216 + 217 + // Set navigation delegate (held strongly in global var) 218 + let delegate = InlineWebViewDelegate() 219 + webView.navigationDelegate = delegate 220 + inlineWebViewDelegate = delegate 221 + 222 + // Add on top of all other subviews 223 + rootView.addSubview(webView) 224 + 225 + // Position: starts at topOffset (from JS getBoundingClientRect), extends to bottom 226 + NSLayoutConstraint.activate([ 227 + webView.topAnchor.constraint(equalTo: rootView.topAnchor, constant: CGFloat(topOffset)), 228 + webView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), 229 + webView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), 230 + webView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor) 231 + ]) 232 + 233 + webView.load(URLRequest(url: url)) 234 + currentInlineWebView = webView 235 + 236 + print("[WebviewPlugin] Inline webview added as subview at topOffset: \(topOffset)") 237 + } 238 + 239 + return true 240 + } 241 + 242 + // C-compatible function to close inline webview 243 + @_cdecl("webview_plugin_close_inline") 244 + public func webviewPluginCloseInline() { 245 + DispatchQueue.main.async { 246 + currentInlineWebView?.removeFromSuperview() 247 + currentInlineWebView = nil 248 + inlineWebViewDelegate = nil 249 + print("[WebviewPlugin] Inline webview removed") 250 + } 251 + }
+2 -1
backend/tauri-mobile/src-tauri/gen/apple/assets/index.html
··· 7 7 <script> 8 8 // Redirect to dev server on localhost 9 9 // iOS simulator can access Mac's localhost 10 - window.location.href = 'http://localhost:63720/'; 10 + // Vite is guaranteed running before app launch (dev-ios-sim.sh waits for it) 11 + window.location.href = 'http://localhost:__DEV_PORT__/'; 11 12 </script> 12 13 </head> 13 14 <body>
+41 -1
backend/tauri-mobile/src-tauri/src/lib.rs
··· 21 21 extern "C" { 22 22 fn webview_plugin_open(url: *const std::ffi::c_char, item_id: *const std::ffi::c_char) -> bool; 23 23 fn webview_plugin_close(); 24 + fn webview_plugin_open_inline(url: *const std::ffi::c_char, top_offset: f64) -> bool; 25 + fn webview_plugin_close_inline(); 24 26 } 25 27 26 28 // Sync version constants — must match backend/version.ts and backend/server/version.js ··· 2597 2599 Ok(()) 2598 2600 } 2599 2601 2602 + /// Open a URL in an inline native WKWebView (added as subview, not modal) 2603 + #[tauri::command] 2604 + async fn open_inline_webview(url: String, top_offset: f64) -> Result<(), String> { 2605 + println!("[Rust] open_inline_webview called for url: {}, top_offset: {}", url, top_offset); 2606 + 2607 + #[cfg(target_os = "ios")] 2608 + { 2609 + use std::ffi::CString; 2610 + let url_cstr = CString::new(url.as_str()).map_err(|e| format!("Invalid URL string: {}", e))?; 2611 + 2612 + let result = unsafe { webview_plugin_open_inline(url_cstr.as_ptr(), top_offset) }; 2613 + if result { 2614 + Ok(()) 2615 + } else { 2616 + Err("Failed to open inline webview".to_string()) 2617 + } 2618 + } 2619 + 2620 + #[cfg(not(target_os = "ios"))] 2621 + { 2622 + let _ = (url, top_offset); 2623 + Ok(()) 2624 + } 2625 + } 2626 + 2627 + /// Close the inline native WKWebView 2628 + #[tauri::command] 2629 + async fn close_inline_webview() -> Result<(), String> { 2630 + #[cfg(target_os = "ios")] 2631 + { 2632 + unsafe { webview_plugin_close_inline() }; 2633 + } 2634 + Ok(()) 2635 + } 2636 + 2600 2637 /// Update a page (URL) item - backward compatible 2601 2638 #[tauri::command] 2602 2639 async fn update_url(id: String, url: String, tags: Vec<String>) -> Result<(), String> { ··· 4853 4890 debug_settings_table, 4854 4891 debug_query_database, 4855 4892 debug_export_database, 4856 - swap_profile_databases 4893 + swap_profile_databases, 4894 + // Inline webview 4895 + open_inline_webview, 4896 + close_inline_webview 4857 4897 ]) 4858 4898 .run(tauri::generate_context!()) 4859 4899 .expect("error while running tauri application");
+3 -3
backend/tauri-mobile/src-tauri/tauri.conf.json
··· 5 5 "identifier": "com.dietrich.peek-mobile", 6 6 "build": { 7 7 "beforeBuildCommand": "npm run build", 8 - "frontendDist": "../dist", 9 - "devUrl": "http://192.168.50.143:63720" 8 + "frontendDist": "../dist" 10 9 }, 11 10 "app": { 12 11 "windows": [ ··· 14 13 "title": "Peek Save", 15 14 "width": 800, 16 15 "height": 600, 17 - "disableInputAccessoryView": true 16 + "disableInputAccessoryView": true, 17 + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1" 18 18 } 19 19 ], 20 20 "security": {
+1 -1
backend/tauri-mobile/src/App.css
··· 3412 3412 /* === Inline Webview (card expansion) === */ 3413 3413 /* Single source of truth for webview animation duration */ 3414 3414 :root { 3415 - --webview-anim: 0.3s; 3415 + --webview-anim: 0.2s; 3416 3416 --webview-ease: cubic-bezier(0.32, 0.72, 0, 1); 3417 3417 --webview-ease-in: cubic-bezier(0.85, 0, 1, 1); /* snap open: strong acceleration */ 3418 3418 --webview-ease-out: cubic-bezier(0.32, 0.72, 0, 1); /* ease out close: decelerate */
+37 -14
backend/tauri-mobile/src/App.tsx
··· 708 708 const [addInputNewTag, setAddInputNewTag] = useState(""); 709 709 710 710 // Webview animation duration (ms) — must match --webview-anim in CSS 711 - const WEBVIEW_ANIM_MS = 300; 711 + const WEBVIEW_ANIM_MS = 200; 712 712 713 - // Inline webview expansion state: which item is expanded with an iframe 713 + // Inline webview expansion state 714 714 const [expandedWebview, setExpandedWebview] = useState<{ 715 715 url: string; 716 716 itemId: string; ··· 2249 2249 } 2250 2250 }; 2251 2251 2252 - const renderWebviewInline = (url: string) => { 2252 + // Create inline native WKWebView via Swift FFI 2253 + const createInlineWebview = async (url: string, itemId: string) => { 2254 + try { 2255 + // Measure the webview-inline div's position so the native WKWebView aligns exactly 2256 + let topOffset = 0; 2257 + const card = document.getElementById(`card-${itemId}`); 2258 + const inlineDiv = card?.querySelector('.webview-inline'); 2259 + if (inlineDiv) { 2260 + topOffset = inlineDiv.getBoundingClientRect().top; 2261 + } 2262 + console.log("[App] Creating inline webview at topOffset:", topOffset, "for:", url); 2263 + await invoke("open_inline_webview", { url, topOffset }); 2264 + setWebviewLoaded(true); 2265 + } catch (error) { 2266 + console.error("[App] Failed to create inline webview:", error); 2267 + setWebviewError(true); 2268 + } 2269 + }; 2270 + 2271 + const destroyInlineWebview = async () => { 2272 + try { 2273 + await invoke("close_inline_webview"); 2274 + } catch (error) { 2275 + console.error("[App] Failed to close inline webview:", error); 2276 + } 2277 + }; 2278 + 2279 + const renderWebviewInline = (_url: string) => { 2253 2280 return ( 2254 2281 <div className={`webview-inline ${webviewAnimated ? 'webview-expanded' : ''}`}> 2255 - {!webviewError ? ( 2256 - <iframe 2257 - src={url} 2258 - className="webview-iframe" 2259 - onLoad={() => setWebviewLoaded(true)} 2260 - onError={() => setWebviewError(true)} 2261 - sandbox="allow-scripts allow-same-origin allow-forms allow-popups" 2262 - /> 2263 - ) : ( 2282 + {webviewError ? ( 2264 2283 <div className="webview-error"> 2265 2284 <p>This site can't be displayed inline.</p> 2266 - <button onClick={(e) => { e.stopPropagation(); openUrl(url); }}>Open in Safari</button> 2285 + <button onClick={(e) => { e.stopPropagation(); openUrl(_url); }}>Open in Safari</button> 2267 2286 </div> 2268 - )} 2287 + ) : null} 2269 2288 {!webviewLoaded && !webviewError && <div className="webview-loading">Loading...</div>} 2270 2289 </div> 2271 2290 ); ··· 2667 2686 // Set open easing on root so all transitions use it 2668 2687 document.documentElement.style.setProperty('--webview-ease', 'var(--webview-ease-in)'); 2669 2688 setWebviewAnimated(true); 2689 + // Create native WKWebView after animation completes 2690 + setTimeout(() => createInlineWebview(url, itemId), WEBVIEW_ANIM_MS); 2670 2691 }); 2671 2692 }); 2672 2693 try { ··· 2677 2698 }; 2678 2699 2679 2700 const closeWebview = () => { 2701 + // Destroy native webview immediately so it doesn't cover the animation 2702 + destroyInlineWebview(); 2680 2703 // Switch to close easing before animating 2681 2704 document.documentElement.style.setProperty('--webview-ease', 'var(--webview-ease-out)'); 2682 2705 setWebviewAnimated(false);