experiments in a post-browser web
10
fork

Configure Feed

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

WIP: mobile reader view and webview plugin changes

+332 -20
+1
backend/tauri-mobile/package.json
··· 30 30 "test:verbose": "VERBOSE=1 node tests/integration.test.js", 31 31 "sim:launch": "xcrun simctl terminate booted com.dietrich.peek-mobile 2>/dev/null; xcrun simctl launch booted com.dietrich.peek-mobile", 32 32 "sim:log": "xcrun simctl spawn booted log stream --predicate 'process == \"Peek Save\"' --level debug", 33 + "sim:log:app": "xcrun simctl spawn booted log stream --predicate 'process == \"Peek Save\" AND messageType >= default' --style compact | grep -E '\\[App\\]|\\[Rust\\]|\\[Peek\\]|\\[WebviewPlugin\\]'", 33 34 "sim:log:webview": "xcrun simctl spawn booted log stream --predicate 'process == \"Peek Save\" AND eventMessage CONTAINS \"webview\"' --style compact", 34 35 "sim:install": "xcrun simctl install booted '/tmp/peek-xcodebuild-sim/Build/Products/debug-iphonesimulator/Peek Save.app'", 35 36 "xcode:clean": "cd src-tauri/gen/apple && xcodebuild -scheme peek-save_iOS -configuration Debug -sdk iphonesimulator -derivedDataPath /tmp/peek-xcodebuild-sim -destination 'platform=iOS Simulator,name=iPhone 17 Pro' clean",
+5 -3
backend/tauri-mobile/src-tauri/gen/apple/Sources/peek-save/WebviewPlugin.swift
··· 341 341 342 342 // C-compatible function to open inline webview with HTML string content (for offline reader mode) 343 343 @_cdecl("webview_plugin_open_inline_html") 344 - public func webviewPluginOpenInlineHtml(htmlPtr: UnsafePointer<CChar>, topOffset: Double) -> Bool { 344 + public func webviewPluginOpenInlineHtml(htmlPtr: UnsafePointer<CChar>, topOffset: Double, baseUrlPtr: UnsafePointer<CChar>) -> Bool { 345 345 let htmlString = String(cString: htmlPtr) 346 - print("[WebviewPlugin] open_inline_html called, topOffset: \(topOffset), html length: \(htmlString.count)") 346 + let baseUrlString = String(cString: baseUrlPtr) 347 + let baseURL = URL(string: baseUrlString) 348 + print("[WebviewPlugin] open_inline_html called, topOffset: \(topOffset), html length: \(htmlString.count), baseURL: \(baseUrlString)") 347 349 348 350 DispatchQueue.main.async { 349 351 // Remove any existing inline webview ··· 373 375 webView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor) 374 376 ]) 375 377 376 - webView.loadHTMLString(htmlString, baseURL: nil) 378 + webView.loadHTMLString(htmlString, baseURL: baseURL) 377 379 currentInlineWebView = webView 378 380 379 381 print("[WebviewPlugin] Inline HTML webview added at topOffset: \(topOffset)")
+161 -15
backend/tauri-mobile/src-tauri/src/lib.rs
··· 15 15 println!("[Peek] {}", msg); 16 16 } 17 17 18 + /// Sanitize reader-mode HTML: strip <style> tags, style/class attributes, script-related content. 19 + /// Readability.js normally strips these, but this is a safety net for the Rust port. 20 + fn sanitize_reader_html(html: &str) -> String { 21 + // Strip <style>...</style> tags (including multiline) 22 + let re_style = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap(); 23 + let result = re_style.replace_all(html, ""); 24 + 25 + // Strip <script>...</script> tags 26 + let re_script = Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap(); 27 + let result = re_script.replace_all(&result, ""); 28 + 29 + // Strip <noscript>...</noscript> tags 30 + let re_noscript = Regex::new(r"(?is)<noscript[^>]*>.*?</noscript>").unwrap(); 31 + let result = re_noscript.replace_all(&result, ""); 32 + 33 + // Strip style="" attributes 34 + let re_style_attr = Regex::new(r#"(?i)\s+style\s*=\s*"[^"]*""#).unwrap(); 35 + let result = re_style_attr.replace_all(&result, ""); 36 + let re_style_attr2 = Regex::new(r#"(?i)\s+style\s*=\s*'[^']*'"#).unwrap(); 37 + let result = re_style_attr2.replace_all(&result, ""); 38 + 39 + // Strip class="" attributes 40 + let re_class_attr = Regex::new(r#"(?i)\s+class\s*=\s*"[^"]*""#).unwrap(); 41 + let result = re_class_attr.replace_all(&result, ""); 42 + let re_class_attr2 = Regex::new(r#"(?i)\s+class\s*=\s*'[^']*'"#).unwrap(); 43 + let result = re_class_attr2.replace_all(&result, ""); 44 + 45 + // Strip event handler attributes (onclick, onerror, onload, etc.) 46 + let re_events = Regex::new(r#"(?i)\s+on\w+\s*=\s*"[^"]*""#).unwrap(); 47 + let result = re_events.replace_all(&result, ""); 48 + let re_events2 = Regex::new(r#"(?i)\s+on\w+\s*=\s*'[^']*'"#).unwrap(); 49 + let result = re_events2.replace_all(&result, ""); 50 + 51 + result.into_owned() 52 + } 53 + 18 54 // FFI declarations for native webview on iOS 19 55 // These symbols are provided by Swift code (WebviewPlugin.swift) at Xcode link time. 20 56 #[cfg(target_os = "ios")] ··· 22 58 fn webview_plugin_open(url: *const std::ffi::c_char, item_id: *const std::ffi::c_char) -> bool; 23 59 fn webview_plugin_close(); 24 60 fn webview_plugin_open_inline(url: *const std::ffi::c_char, top_offset: f64, item_id: *const std::ffi::c_char) -> bool; 25 - fn webview_plugin_open_inline_html(html: *const std::ffi::c_char, top_offset: f64) -> bool; 61 + fn webview_plugin_open_inline_html(html: *const std::ffi::c_char, top_offset: f64, base_url: *const std::ffi::c_char) -> bool; 26 62 fn webview_plugin_close_inline(); 27 63 } 28 64 ··· 2901 2937 async fn open_inline_webview_html(item_id: String, top_offset: f64) -> Result<(), String> { 2902 2938 println!("[Rust] open_inline_webview_html called for item_id: {}, top_offset: {}", item_id, top_offset); 2903 2939 2904 - // Read the offline content 2940 + // Look up the original URL for this item (needed for resolving relative image/link URLs) 2941 + let conn = get_connection()?; 2942 + let original_url: Option<String> = conn 2943 + .query_row( 2944 + "SELECT url FROM items WHERE id = ? AND deleted_at IS NULL", 2945 + params![&item_id], 2946 + |row| row.get(0), 2947 + ) 2948 + .ok(); 2949 + 2950 + // Read the offline content and metadata 2905 2951 let container = get_container_path() 2906 2952 .ok_or_else(|| "No container path".to_string())?; 2907 - let content_path = container.join("offline").join(&item_id).join("content.html"); 2953 + let offline_dir = container.join("offline").join(&item_id); 2954 + let content_path = offline_dir.join("content.html"); 2908 2955 if !content_path.exists() { 2909 2956 return Err("No offline content available".to_string()); 2910 2957 } 2911 2958 let html = fs::read_to_string(&content_path) 2912 2959 .map_err(|e| format!("Failed to read offline content: {}", e))?; 2913 2960 2914 - // Wrap in a styled reader-mode shell 2961 + // Load article metadata if available 2962 + let meta_path = offline_dir.join("meta.json"); 2963 + let meta: serde_json::Value = if meta_path.exists() { 2964 + let meta_str = fs::read_to_string(&meta_path).unwrap_or_default(); 2965 + serde_json::from_str(&meta_str).unwrap_or(serde_json::json!({})) 2966 + } else { 2967 + serde_json::json!({}) 2968 + }; 2969 + let title = meta["title"].as_str().unwrap_or(""); 2970 + let byline = meta["byline"].as_str().unwrap_or(""); 2971 + let dir = meta["dir"].as_str().unwrap_or(""); 2972 + 2973 + // Extract domain from URL for header 2974 + let domain = original_url.as_ref() 2975 + .and_then(|u| url::Url::parse(u).ok()) 2976 + .and_then(|u| u.host_str().map(|h| h.to_string())) 2977 + .unwrap_or_default(); 2978 + 2979 + // Build <base> tag so relative image/link URLs resolve against the original page 2980 + let base_tag = if let Some(ref url) = original_url { 2981 + format!(r#"<base href="{}">"#, url.replace('"', "&quot;")) 2982 + } else { 2983 + String::new() 2984 + }; 2985 + 2986 + // Dir attribute for RTL support 2987 + let dir_attr = if !dir.is_empty() { 2988 + format!(r#" dir="{}""#, dir) 2989 + } else { 2990 + String::new() 2991 + }; 2992 + 2993 + // Build header section 2994 + let header_html = if !title.is_empty() || !domain.is_empty() { 2995 + let domain_link = if let Some(ref url) = original_url { 2996 + format!(r#"<a class="domain" href="{}">{}</a>"#, url.replace('"', "&quot;"), domain) 2997 + } else { 2998 + String::new() 2999 + }; 3000 + let byline_html = if !byline.is_empty() { 3001 + format!(r#"<div class="credits">{}</div>"#, byline) 3002 + } else { 3003 + String::new() 3004 + }; 3005 + format!(r#"<div class="header">{}<h1>{}</h1>{}</div>"#, 3006 + domain_link, title, byline_html) 3007 + } else { 3008 + String::new() 3009 + }; 3010 + 3011 + // Load reader CSS: check container/offline/reader.css first (user-editable), 3012 + // fall back to compiled-in default from src/reader.css 3013 + let reader_css = { 3014 + let default_css = include_str!("../../src/reader.css"); 3015 + if let Some(ref container) = Some(container.clone()) { 3016 + let custom_css_path = container.join("offline").join("reader.css"); 3017 + if custom_css_path.exists() { 3018 + fs::read_to_string(&custom_css_path).unwrap_or_else(|_| default_css.to_string()) 3019 + } else { 3020 + // Write default CSS to container so it can be edited without recompiling 3021 + let offline_dir = container.join("offline"); 3022 + let _ = fs::create_dir_all(&offline_dir); 3023 + let _ = fs::write(&custom_css_path, default_css); 3024 + default_css.to_string() 3025 + } 3026 + } else { 3027 + default_css.to_string() 3028 + } 3029 + }; 3030 + 3031 + // Build reader mode HTML with external-ish CSS 2915 3032 let styled_html = format!(r#"<!DOCTYPE html> 2916 - <html><head> 2917 - <meta name="viewport" content="width=device-width, initial-scale=1"> 3033 + <html{dir_attr}> 3034 + <head> 3035 + <meta charset="utf-8"> 3036 + <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"> 3037 + {base_tag} 2918 3038 <style> 2919 - body {{ font-family: -apple-system, system-ui, sans-serif; padding: 16px; line-height: 1.6; color: #333; max-width: 100%; overflow-x: hidden; }} 2920 - img {{ max-width: 100%; height: auto; }} 2921 - pre {{ overflow-x: auto; }} 2922 - @media (prefers-color-scheme: dark) {{ 2923 - body {{ background: #1a1a1a; color: #ddd; }} 2924 - a {{ color: #6cb4ee; }} 2925 - }} 3039 + {reader_css} 2926 3040 </style> 2927 - </head><body>{}</body></html>"#, html); 3041 + </head> 3042 + <body> 3043 + <div class="container"> 3044 + {header_html} 3045 + <div class="reader-content"> 3046 + {html} 3047 + </div> 3048 + </div> 3049 + </body> 3050 + </html>"#, 3051 + dir_attr = dir_attr, 3052 + base_tag = base_tag, 3053 + reader_css = reader_css, 3054 + header_html = header_html, 3055 + html = html); 2928 3056 2929 3057 #[cfg(target_os = "ios")] 2930 3058 { 2931 3059 use std::ffi::CString; 2932 3060 let html_cstr = CString::new(styled_html).map_err(|e| format!("Invalid HTML content: {}", e))?; 2933 - let result = unsafe { webview_plugin_open_inline_html(html_cstr.as_ptr(), top_offset) }; 3061 + let base_url_str = original_url.unwrap_or_default(); 3062 + let base_url_cstr = CString::new(base_url_str.as_str()).map_err(|e| format!("Invalid base URL: {}", e))?; 3063 + let result = unsafe { webview_plugin_open_inline_html(html_cstr.as_ptr(), top_offset, base_url_cstr.as_ptr()) }; 2934 3064 if result { 2935 3065 Ok(()) 2936 3066 } else { ··· 4286 4416 let content = article.content 4287 4417 .ok_or_else(|| "Readability extracted no content from page".to_string())?; 4288 4418 4419 + // Sanitize: strip any remaining <style> tags, style/class attributes as safety net 4420 + let content = sanitize_reader_html(&content); 4421 + 4289 4422 // Store to filesystem 4290 4423 let container = get_container_path() 4291 4424 .ok_or_else(|| "No container path".to_string())?; ··· 4297 4430 let size_bytes = content.len() as i64; 4298 4431 fs::write(&content_path, &content) 4299 4432 .map_err(|e| format!("Failed to write offline content: {}", e))?; 4433 + 4434 + // Store article metadata (title, byline, etc.) for reader template 4435 + let meta = serde_json::json!({ 4436 + "title": article.title, 4437 + "byline": article.byline, 4438 + "site_name": article.site_name, 4439 + "excerpt": article.excerpt, 4440 + "lang": article.lang, 4441 + "dir": article.dir, 4442 + }); 4443 + let meta_path = offline_dir.join("meta.json"); 4444 + fs::write(&meta_path, meta.to_string()) 4445 + .map_err(|e| format!("Failed to write offline metadata: {}", e))?; 4300 4446 4301 4447 // Update index in SQLite 4302 4448 let conn = get_connection()?;
+1 -1
backend/tauri-mobile/src-tauri/tauri.conf.json
··· 6 6 "build": { 7 7 "beforeBuildCommand": "npm run build", 8 8 "frontendDist": "../dist", 9 - "devUrl": "http://192.168.50.143:59780" 9 + "devUrl": "http://192.168.50.143:64872" 10 10 }, 11 11 "app": { 12 12 "windows": [
+1 -1
backend/tauri-mobile/src/App.tsx
··· 2447 2447 {offlineTag && item.tags.includes(offlineTag) && !isExpanded && ( 2448 2448 <button 2449 2449 className="card-action-btn" 2450 - onClick={(e) => openOfflineReader(item.id, e)} 2450 + onClick={(e) => { console.log("[App] EYE ICON TAPPED for", item.id); openOfflineReader(item.id, e); }} 2451 2451 title="Read offline" 2452 2452 > 2453 2453 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+163
backend/tauri-mobile/src/reader.css
··· 1 + /* Reader Mode Stylesheet 2 + * Based on Firefox Reader View (aboutReader.css) and Safari Reader patterns. 3 + * Edit this file to customize reader appearance — no Rust rebuild needed. 4 + */ 5 + 6 + * { box-sizing: border-box; } 7 + 8 + body { 9 + margin: 0; padding: 20px; 10 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; 11 + color: #333; background: #fff; 12 + -webkit-text-size-adjust: 100%; 13 + } 14 + @media (prefers-color-scheme: dark) { 15 + body { color: #e0e0e0; background: #1a1a2e; } 16 + } 17 + 18 + /* Container */ 19 + .container { 20 + max-width: 38em; 21 + margin: 0 auto; 22 + font-size: 18px; 23 + line-height: 1.6; 24 + } 25 + 26 + /* Header */ 27 + .header { margin-bottom: 24px; } 28 + .domain { 29 + font-size: 0.85em; 30 + color: #0095dd; 31 + text-decoration: none; 32 + font-family: -apple-system, sans-serif; 33 + } 34 + .header h1 { 35 + font-size: 1.6em; 36 + line-height: 1.25; 37 + margin: 12px 0; 38 + font-weight: bold; 39 + } 40 + .credits { 41 + font-size: 0.9em; 42 + font-style: italic; 43 + opacity: 0.7; 44 + } 45 + 46 + /* Content */ 47 + .reader-content { 48 + text-rendering: optimizeLegibility; 49 + -webkit-font-smoothing: antialiased; 50 + } 51 + .reader-content h1 { font-size: 1.6em; line-height: 1.25; font-weight: bold; margin: 1.2em 0 0.6em; } 52 + .reader-content h2 { font-size: 1.2em; line-height: 1.5; font-weight: bold; margin: 1.2em 0 0.6em; } 53 + .reader-content h3 { font-size: 1em; line-height: 1.66; font-weight: bold; margin: 1em 0 0.5em; } 54 + .reader-content p { margin: 0 0 1em; } 55 + 56 + /* Links */ 57 + .reader-content a { color: #0095dd; text-decoration: underline; } 58 + .reader-content a:visited { color: #cc22ee; } 59 + @media (prefers-color-scheme: dark) { 60 + .reader-content a { color: #6cb4ee; } 61 + .reader-content a:visited { color: #cc88ff; } 62 + } 63 + 64 + /* Images */ 65 + .reader-content * { max-width: 100%; height: auto; } 66 + .reader-content img { 67 + display: block; 68 + margin: 0.5em auto; 69 + height: auto; 70 + } 71 + .reader-content img[width="1"], 72 + .reader-content img[height="1"] { display: none; } /* tracking pixels */ 73 + 74 + /* Figures and captions */ 75 + .reader-content figure { margin: 1em 0; } 76 + .reader-content figcaption { 77 + font-size: 0.8em; 78 + line-height: 1.5; 79 + font-style: italic; 80 + text-align: center; 81 + opacity: 0.7; 82 + margin-top: 0.5em; 83 + } 84 + 85 + /* Code */ 86 + .reader-content code, 87 + .reader-content pre { 88 + font-family: "SF Mono", Menlo, Consolas, monospace; 89 + font-size: 0.9em; 90 + } 91 + .reader-content pre { 92 + white-space: pre-wrap; 93 + padding: 12px; 94 + border-radius: 6px; 95 + background: rgba(0,0,0,0.05); 96 + overflow-x: auto; 97 + margin: 1em 0; 98 + } 99 + @media (prefers-color-scheme: dark) { 100 + .reader-content pre { background: rgba(255,255,255,0.08); } 101 + } 102 + .reader-content code { 103 + padding: 0.1em 0.3em; 104 + border-radius: 3px; 105 + background: rgba(0,0,0,0.04); 106 + } 107 + .reader-content pre code { padding: 0; background: none; } 108 + @media (prefers-color-scheme: dark) { 109 + .reader-content code { background: rgba(255,255,255,0.06); } 110 + } 111 + 112 + /* Blockquotes */ 113 + .reader-content blockquote { 114 + margin: 0 0 1em; 115 + padding: 0 0 0 16px; 116 + border-left: 2px solid currentColor; 117 + opacity: 0.85; 118 + } 119 + 120 + /* Lists */ 121 + .reader-content ul { padding-left: 30px; list-style: disc; } 122 + .reader-content ol { padding-left: 30px; list-style: decimal; } 123 + .reader-content li { margin-bottom: 0.4em; } 124 + 125 + /* Tables */ 126 + .reader-content table { 127 + border-collapse: collapse; 128 + margin: 1em 0; 129 + font-size: 0.9em; 130 + word-wrap: break-word; 131 + } 132 + .reader-content th, 133 + .reader-content td { 134 + border: 1px solid rgba(0,0,0,0.12); 135 + padding: 6px 8px; 136 + vertical-align: top; 137 + } 138 + .reader-content th { background: rgba(0,0,0,0.03); font-weight: bold; } 139 + @media (prefers-color-scheme: dark) { 140 + .reader-content th, 141 + .reader-content td { border-color: rgba(255,255,255,0.15); } 142 + .reader-content th { background: rgba(255,255,255,0.05); } 143 + } 144 + 145 + /* Horizontal rules */ 146 + .reader-content hr { 147 + border: none; 148 + border-top: 1px solid rgba(0,0,0,0.12); 149 + margin: 2em 0; 150 + } 151 + @media (prefers-color-scheme: dark) { 152 + .reader-content hr { border-color: rgba(255,255,255,0.15); } 153 + } 154 + 155 + /* Hide empty wrappers and trailing BRs (Safari approach) */ 156 + .reader-content p > p:empty, 157 + .reader-content div > p:empty, 158 + .reader-content p + br, 159 + .reader-content p > br:only-child, 160 + .reader-content img + br { display: none; } 161 + 162 + /* Hidden/screen-reader elements */ 163 + .reader-content [hidden] { display: none; }