experiments in a post-browser web
10
fork

Configure Feed

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

feat(mobile): add iOS profile support with build detection

- Auto-detect App Store/TestFlight vs Xcode builds via receipt check
- Per-profile database files (peek-default.db, peek-dev.db, etc.)
- Append ?profile= to all sync URLs for server-side isolation
- Profile selector in Settings with quick-switch buttons
- Visual banner when not on default profile
- Debug tools for container inspection and database export
- Build scripts for clean iOS rebuilds (npm run rebuild:ios)

+1454 -93
+5
backend/tauri-mobile/package.json
··· 18 18 "build:release:full": "./build-release.sh --force", 19 19 "seed": "./seed-test-data.sh", 20 20 "reset:server": "rm -rf ../server/data && echo 'Server data reset'", 21 + "clean:rust": "cd src-tauri && rm -rf target/aarch64-apple-ios-sim/debug/build/peek-save-* target/aarch64-apple-ios/release/build/peek-save-* 2>/dev/null; touch build.rs; echo 'Rust bridge cache cleaned'", 22 + "clean:ios": "cd src-tauri && rm -rf target/aarch64-apple-ios-sim/debug/build/peek-save-* 2>/dev/null; touch build.rs; echo 'iOS simulator cache cleaned'", 23 + "clean:ios:release": "cd src-tauri && rm -rf target/aarch64-apple-ios/release/build/peek-save-* 2>/dev/null; touch build.rs; echo 'iOS release cache cleaned'", 24 + "rebuild:ios": "npm run clean:ios && npm run build:ios", 25 + "rebuild:ios:release": "npm run clean:ios:release && npm run build:ios:release", 21 26 "test": "node tests/integration.test.js", 22 27 "test:verbose": "VERBOSE=1 node tests/integration.test.js" 23 28 },
+14
backend/tauri-mobile/src-tauri/AppGroupBridge.m
··· 42 42 return 0; 43 43 } 44 44 45 + // Returns 1 if this is an App Store or TestFlight build, 0 if Xcode install 46 + // App Store/TestFlight builds have a receipt file, Xcode installs don't 47 + int is_app_store_build() { 48 + NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL]; 49 + if (receiptURL == nil) { 50 + NSLog(@"[AppGroupBridge] No receipt URL - Xcode build"); 51 + return 0; 52 + } 53 + 54 + BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:receiptURL.path]; 55 + NSLog(@"[AppGroupBridge] Receipt exists: %d at %@", exists, receiptURL.path); 56 + return exists ? 1 : 0; 57 + } 58 + 45 59 // Returns the path to the App Group container directory 46 60 // This is where the SQLite database will be stored 47 61 const char* get_app_group_container_path() {
+649 -79
backend/tauri-mobile/src-tauri/src/lib.rs
··· 4 4 use std::ffi::CStr; 5 5 use std::os::raw::c_char; 6 6 use std::path::PathBuf; 7 + use std::fs; 8 + use std::sync::RwLock; 7 9 use reqwest; 8 10 use regex::Regex; 11 + use tauri::Manager; 9 12 10 13 // Legacy model for backward compatibility (webhook, etc.) 11 14 #[derive(Debug, Clone, Serialize, Deserialize)] ··· 130 133 extern "C" { 131 134 fn get_app_group_container_path() -> *const c_char; 132 135 fn get_system_is_dark_mode() -> i32; 136 + fn is_app_store_build() -> i32; 137 + } 138 + 139 + /// Check if this is an App Store/TestFlight build (has receipt) vs Xcode install 140 + fn is_production_build() -> bool { 141 + unsafe { is_app_store_build() == 1 } 133 142 } 134 143 135 - fn get_db_path() -> Option<PathBuf> { 144 + /// Get the default profile based on build type 145 + fn get_default_profile() -> String { 146 + if is_production_build() { 147 + "default".to_string() 148 + } else { 149 + "dev".to_string() 150 + } 151 + } 152 + 153 + // ============================================================================ 154 + // Profile Management System 155 + // ============================================================================ 156 + 157 + /// Profile configuration stored in profiles.json 158 + #[derive(Debug, Clone, Serialize, Deserialize)] 159 + struct ProfileConfig { 160 + current: String, 161 + profiles: Vec<ProfileEntry>, 162 + } 163 + 164 + #[derive(Debug, Clone, Serialize, Deserialize)] 165 + struct ProfileEntry { 166 + slug: String, 167 + name: String, 168 + } 169 + 170 + /// Cached profile config to avoid repeated file reads 171 + static PROFILE_CONFIG: RwLock<Option<ProfileConfig>> = RwLock::new(None); 172 + 173 + /// Get the App Group container path 174 + fn get_container_path() -> Option<PathBuf> { 136 175 unsafe { 137 176 let c_str = get_app_group_container_path(); 138 177 if c_str.is_null() { ··· 141 180 } 142 181 let path_str = CStr::from_ptr(c_str).to_string_lossy().to_string(); 143 182 libc::free(c_str as *mut libc::c_void); 144 - Some(PathBuf::from(path_str).join("peek.db")) 183 + Some(PathBuf::from(path_str)) 145 184 } 146 185 } 147 186 187 + /// Get the path to profiles.json 188 + fn get_profiles_config_path() -> Option<PathBuf> { 189 + get_container_path().map(|p| p.join("profiles.json")) 190 + } 191 + 192 + /// Load profile config from file, creating default if needed 193 + fn load_profile_config() -> ProfileConfig { 194 + // Check cache first 195 + if let Ok(guard) = PROFILE_CONFIG.read() { 196 + if let Some(ref config) = *guard { 197 + return config.clone(); 198 + } 199 + } 200 + 201 + let config = load_profile_config_from_file(); 202 + 203 + // Cache the loaded config 204 + if let Ok(mut guard) = PROFILE_CONFIG.write() { 205 + *guard = Some(config.clone()); 206 + } 207 + 208 + config 209 + } 210 + 211 + /// Load profile config directly from file (bypasses cache) 212 + fn load_profile_config_from_file() -> ProfileConfig { 213 + let config_path = match get_profiles_config_path() { 214 + Some(p) => p, 215 + None => { 216 + return create_default_profile_config(); 217 + } 218 + }; 219 + 220 + if config_path.exists() { 221 + match fs::read_to_string(&config_path) { 222 + Ok(contents) => { 223 + match serde_json::from_str::<ProfileConfig>(&contents) { 224 + Ok(config) => { 225 + println!("[Rust] Loaded profile config: current={}", config.current); 226 + return config; 227 + } 228 + Err(e) => { 229 + println!("[Rust] Failed to parse profiles.json: {}", e); 230 + } 231 + } 232 + } 233 + Err(e) => { 234 + println!("[Rust] Failed to read profiles.json: {}", e); 235 + } 236 + } 237 + } 238 + 239 + // Create default config 240 + let config = create_default_profile_config(); 241 + save_profile_config(&config); 242 + config 243 + } 244 + 245 + /// Create default profile config based on build type 246 + fn create_default_profile_config() -> ProfileConfig { 247 + let default_profile = get_default_profile(); 248 + println!("[Rust] Creating default profile config with profile: {}", default_profile); 249 + 250 + ProfileConfig { 251 + current: default_profile.clone(), 252 + profiles: vec![ 253 + ProfileEntry { 254 + slug: "default".to_string(), 255 + name: "Default".to_string(), 256 + }, 257 + ProfileEntry { 258 + slug: "dev".to_string(), 259 + name: "Development".to_string(), 260 + }, 261 + ], 262 + } 263 + } 264 + 265 + /// Save profile config to file 266 + fn save_profile_config(config: &ProfileConfig) -> bool { 267 + let config_path = match get_profiles_config_path() { 268 + Some(p) => p, 269 + None => return false, 270 + }; 271 + 272 + match serde_json::to_string_pretty(config) { 273 + Ok(json) => { 274 + match fs::write(&config_path, json) { 275 + Ok(_) => { 276 + println!("[Rust] Saved profile config"); 277 + // Update cache 278 + if let Ok(mut guard) = PROFILE_CONFIG.write() { 279 + *guard = Some(config.clone()); 280 + } 281 + true 282 + } 283 + Err(e) => { 284 + println!("[Rust] Failed to write profiles.json: {}", e); 285 + false 286 + } 287 + } 288 + } 289 + Err(e) => { 290 + println!("[Rust] Failed to serialize profile config: {}", e); 291 + false 292 + } 293 + } 294 + } 295 + 296 + /// Get the current profile slug (from profiles.json) 297 + fn get_current_profile_slug() -> Result<String, String> { 298 + Ok(load_profile_config().current) 299 + } 300 + 301 + /// Append profile parameter to a URL 302 + fn append_profile_to_url(url: &str) -> Result<String, String> { 303 + let profile = get_current_profile_slug()?; 304 + let separator = if url.contains('?') { "&" } else { "?" }; 305 + Ok(format!("{}{}profile={}", url, separator, profile)) 306 + } 307 + 308 + fn get_db_path() -> Option<PathBuf> { 309 + let container_path = get_container_path()?; 310 + 311 + // Use profile from config file for database selection 312 + // This allows user to switch profiles and see different data 313 + let config = load_profile_config(); 314 + let profile = &config.current; 315 + let db_name = format!("peek-{}.db", profile); 316 + let new_db_path = container_path.join(&db_name); 317 + 318 + // Migration: rename old peek.db to the DEFAULT profile's database 319 + // This runs once when upgrading from old single-database version 320 + // TODO: Remove this transitional code after all users have migrated 321 + let old_db_path = container_path.join("peek.db"); 322 + let default_profile = get_default_profile(); 323 + let default_db_path = container_path.join(format!("peek-{}.db", default_profile)); 324 + if old_db_path.exists() && !default_db_path.exists() { 325 + println!("[Rust] Migrating {} to {}", old_db_path.display(), default_db_path.display()); 326 + if let Err(e) = fs::rename(&old_db_path, &default_db_path) { 327 + println!("[Rust] Migration failed: {}. Will use new empty database.", e); 328 + } else { 329 + println!("[Rust] Migration successful"); 330 + } 331 + } 332 + 333 + println!("[Rust] Using database: {} (profile: {})", db_name, profile); 334 + Some(new_db_path) 335 + } 336 + 148 337 use std::sync::Once; 149 338 150 339 static DB_INIT: Once = Once::new(); ··· 447 636 Ok(conn) 448 637 } 449 638 450 - #[tauri::command] 451 - fn debug_list_container_files() -> Result<Vec<String>, String> { 452 - unsafe { 453 - let c_str = get_app_group_container_path(); 454 - if c_str.is_null() { 455 - return Err("Failed to get App Group container path".to_string()); 456 - } 457 - let path_str = CStr::from_ptr(c_str).to_string_lossy().to_string(); 458 - libc::free(c_str as *mut libc::c_void); 459 - 460 - let container_path = PathBuf::from(&path_str); 461 - let mut files = Vec::new(); 462 - 463 - files.push(format!("Container: {}", path_str)); 464 - 465 - fn list_recursive(dir: &PathBuf, prefix: &str, files: &mut Vec<String>) { 466 - if let Ok(entries) = std::fs::read_dir(dir) { 467 - for entry in entries.flatten() { 468 - let path = entry.path(); 469 - let name = entry.file_name().to_string_lossy().to_string(); 470 - if path.is_dir() { 471 - files.push(format!("{}{}/", prefix, name)); 472 - list_recursive(&path, &format!("{} ", prefix), files); 473 - } else { 474 - let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); 475 - files.push(format!("{}{} ({} bytes)", prefix, name, size)); 476 - } 477 - } 478 - } 479 - } 480 - 481 - list_recursive(&container_path, " ", &mut files); 482 - Ok(files) 483 - } 484 - } 485 - 486 639 // Parse hashtags from text content 487 640 fn parse_hashtags(content: &str) -> Vec<String> { 488 641 let re = Regex::new(r"#(\w+)").unwrap(); ··· 532 685 async fn push_url_to_webhook(saved_url: SavedUrl) { 533 686 let (webhook_url, api_key) = get_webhook_config(); 534 687 535 - if let Some(url) = webhook_url { 536 - if !url.is_empty() { 688 + if let Some(base_url) = webhook_url { 689 + if !base_url.is_empty() { 690 + // Append profile to webhook URL 691 + let url = match append_profile_to_url(&base_url) { 692 + Ok(u) => u, 693 + Err(e) => { 694 + println!("[Rust] Failed to append profile to URL: {}", e); 695 + return; 696 + } 697 + }; 537 698 println!("[Rust] Pushing URL to webhook: {}", saved_url.url); 538 699 let client = reqwest::Client::new(); 539 700 let payload = WebhookPayload { ··· 570 731 async fn push_text_to_webhook(saved_text: SavedText) { 571 732 let (webhook_url, api_key) = get_webhook_config(); 572 733 573 - if let Some(url) = webhook_url { 574 - if !url.is_empty() { 734 + if let Some(base_url) = webhook_url { 735 + if !base_url.is_empty() { 736 + // Append profile to webhook URL 737 + let url = match append_profile_to_url(&base_url) { 738 + Ok(u) => u, 739 + Err(e) => { 740 + println!("[Rust] Failed to append profile to URL: {}", e); 741 + return; 742 + } 743 + }; 575 744 println!("[Rust] Pushing text to webhook"); 576 745 let client = reqwest::Client::new(); 577 746 let payload = WebhookPayload { ··· 608 777 async fn push_tagset_to_webhook(saved_tagset: SavedTagset) { 609 778 let (webhook_url, api_key) = get_webhook_config(); 610 779 611 - if let Some(url) = webhook_url { 612 - if !url.is_empty() { 780 + if let Some(base_url) = webhook_url { 781 + if !base_url.is_empty() { 782 + // Append profile to webhook URL 783 + let url = match append_profile_to_url(&base_url) { 784 + Ok(u) => u, 785 + Err(e) => { 786 + println!("[Rust] Failed to append profile to URL: {}", e); 787 + return; 788 + } 789 + }; 613 790 println!("[Rust] Pushing tagset to webhook"); 614 791 let client = reqwest::Client::new(); 615 792 let payload = WebhookPayload { ··· 650 827 } 651 828 652 829 #[tauri::command] 830 + fn debug_list_container_files() -> Result<Vec<String>, String> { 831 + unsafe { 832 + let c_str = get_app_group_container_path(); 833 + if c_str.is_null() { 834 + return Err("Failed to get App Group container path".to_string()); 835 + } 836 + let path_str = CStr::from_ptr(c_str).to_string_lossy().to_string(); 837 + libc::free(c_str as *mut libc::c_void); 838 + 839 + let container_path = PathBuf::from(&path_str); 840 + 841 + let mut files = Vec::new(); 842 + files.push(format!("Container: {}", path_str)); 843 + 844 + // List all files including hidden ones 845 + if let Ok(entries) = std::fs::read_dir(&container_path) { 846 + for entry in entries.flatten() { 847 + let path = entry.path(); 848 + let metadata = std::fs::metadata(&path); 849 + let size = metadata.map(|m| m.len()).unwrap_or(0); 850 + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); 851 + files.push(format!("{} ({} bytes)", name, size)); 852 + } 853 + } 854 + 855 + // Also check for WAL and SHM files 856 + let wal_path = container_path.join("peek.db-wal"); 857 + let shm_path = container_path.join("peek.db-shm"); 858 + if wal_path.exists() { 859 + let size = std::fs::metadata(&wal_path).map(|m| m.len()).unwrap_or(0); 860 + files.push(format!("peek.db-wal ({} bytes)", size)); 861 + } 862 + if shm_path.exists() { 863 + let size = std::fs::metadata(&shm_path).map(|m| m.len()).unwrap_or(0); 864 + files.push(format!("peek.db-shm ({} bytes)", size)); 865 + } 866 + 867 + // Check Library folder for other databases 868 + let lib_path = container_path.join("Library"); 869 + if lib_path.exists() { 870 + files.push("--- Library folder: ---".to_string()); 871 + if let Ok(entries) = std::fs::read_dir(&lib_path) { 872 + for entry in entries.flatten() { 873 + let path = entry.path(); 874 + let metadata = std::fs::metadata(&path); 875 + let size = metadata.map(|m| m.len()).unwrap_or(0); 876 + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); 877 + files.push(format!(" {} ({} bytes)", name, size)); 878 + } 879 + } 880 + } 881 + 882 + Ok(files) 883 + } 884 + } 885 + 886 + #[tauri::command] 887 + fn debug_query_database() -> Result<String, String> { 888 + let db_path = get_db_path().ok_or("Failed to get database path")?; 889 + let conn = Connection::open(&db_path).map_err(|e| format!("Failed to open database: {}", e))?; 890 + 891 + let mut info = Vec::new(); 892 + info.push(format!("DB: {:?}", db_path)); 893 + 894 + // List all tables 895 + let tables: Vec<String> = conn 896 + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") 897 + .map_err(|e| e.to_string())? 898 + .query_map([], |row| row.get(0)) 899 + .map_err(|e| e.to_string())? 900 + .filter_map(|r| r.ok()) 901 + .collect(); 902 + info.push(format!("Tables: {:?}", tables)); 903 + 904 + // Count items in each relevant table (including old schema) 905 + for table in &["items", "urls", "texts", "tagsets", "tags", "addresses", "visits", "content"] { 906 + let count: Result<i64, _> = conn.query_row( 907 + &format!("SELECT COUNT(*) FROM {}", table), 908 + [], 909 + |row| row.get(0), 910 + ); 911 + match count { 912 + Ok(c) => info.push(format!("{}: {} rows", table, c)), 913 + Err(_) => info.push(format!("{}: (not found)", table)), 914 + } 915 + } 916 + 917 + // Check for deleted items 918 + let deleted_count: Result<i64, _> = conn.query_row( 919 + "SELECT COUNT(*) FROM items WHERE deleted = 1", 920 + [], 921 + |row| row.get(0), 922 + ); 923 + match deleted_count { 924 + Ok(c) => info.push(format!("deleted: {}", c)), 925 + Err(_) => {} 926 + } 927 + 928 + // Check distinct device_ids 929 + let device_ids: Result<Vec<String>, _> = conn 930 + .prepare("SELECT DISTINCT device_id FROM items WHERE device_id IS NOT NULL") 931 + .and_then(|mut stmt| { 932 + stmt.query_map([], |row| row.get(0)) 933 + .map(|rows| rows.filter_map(|r| r.ok()).collect()) 934 + }); 935 + match device_ids { 936 + Ok(ids) => info.push(format!("device_ids: {:?}", ids)), 937 + Err(_) => info.push("device_id: (no column)".to_string()), 938 + } 939 + 940 + // Get current device_id from settings 941 + let current_device: Result<String, _> = conn.query_row( 942 + "SELECT value FROM settings WHERE key = 'device_id'", 943 + [], 944 + |row| row.get(0), 945 + ); 946 + match current_device { 947 + Ok(id) => info.push(format!("current device: {}", id)), 948 + Err(_) => info.push("current device: (not set)".to_string()), 949 + } 950 + 951 + // Sample first few items if any exist 952 + let sample: Result<Vec<String>, _> = conn 953 + .prepare("SELECT id, type, content, device_id FROM items LIMIT 3") 954 + .and_then(|mut stmt| { 955 + stmt.query_map([], |row| { 956 + let id: String = row.get(0)?; 957 + let item_type: String = row.get(1)?; 958 + let content: String = row.get::<_, String>(2).unwrap_or_default(); 959 + let device: String = row.get::<_, String>(3).unwrap_or_default(); 960 + Ok(format!("{}:{}:{:.20}...(dev:{})", id, item_type, content, device)) 961 + }) 962 + .map(|rows| rows.filter_map(|r| r.ok()).collect()) 963 + }); 964 + match sample { 965 + Ok(items) if !items.is_empty() => info.push(format!("sample: {:?}", items)), 966 + _ => {} 967 + } 968 + 969 + Ok(info.join(" | ")) 970 + } 971 + 972 + #[tauri::command] 973 + fn debug_export_database(app: tauri::AppHandle) -> Result<String, String> { 974 + let db_path = get_db_path().ok_or("Failed to get database path")?; 975 + let data = std::fs::read(&db_path).map_err(|e| format!("Failed to read database: {}", e))?; 976 + 977 + // Get app's data directory (accessible via Download Container) 978 + let app_data = app.path().app_data_dir() 979 + .map_err(|e| format!("Failed to get app data dir: {}", e))?; 980 + std::fs::create_dir_all(&app_data) 981 + .map_err(|e| format!("Failed to create app data dir: {}", e))?; 982 + 983 + // Copy raw database 984 + let export_db = app_data.join("peek-export.db"); 985 + std::fs::write(&export_db, &data) 986 + .map_err(|e| format!("Failed to write database: {}", e))?; 987 + 988 + // Also create base64 version 989 + use base64::{Engine as _, engine::general_purpose::STANDARD}; 990 + let encoded = STANDARD.encode(&data); 991 + let export_b64 = app_data.join("peek-export.b64"); 992 + std::fs::write(&export_b64, &encoded) 993 + .map_err(|e| format!("Failed to write base64: {}", e))?; 994 + 995 + Ok(format!("Exported {} bytes to app data dir", data.len())) 996 + } 997 + 998 + #[tauri::command] 653 999 async fn save_url(url: String, tags: Vec<String>, metadata: Option<serde_json::Value>) -> Result<(), String> { 654 1000 println!("[Rust] save_url called with url: {}, tags: {:?}, metadata: {:?}", url, tags, metadata); 655 1001 ··· 751 1097 752 1098 println!("[Rust] Page saved successfully"); 753 1099 754 - // Push to webhook (fire and forget - don't block on it) 755 - let saved_url = SavedUrl { 756 - id: item_id, 757 - url, 758 - tags, 759 - saved_at: now, 760 - metadata, 761 - }; 1100 + // Trigger auto-sync if enabled (fire and forget) 762 1101 tauri::async_runtime::spawn(async move { 763 - push_url_to_webhook(saved_url).await; 1102 + trigger_auto_sync_if_enabled().await; 764 1103 }); 765 1104 766 1105 Ok(()) ··· 1278 1617 1279 1618 println!("[Rust] Text saved successfully"); 1280 1619 1281 - // Push to webhook (fire and forget) 1282 - let saved_text = SavedText { 1283 - id, 1284 - content, 1285 - tags: all_tags, 1286 - saved_at: now, 1287 - metadata, 1288 - }; 1620 + // Trigger auto-sync if enabled (fire and forget) 1289 1621 tauri::async_runtime::spawn(async move { 1290 - push_text_to_webhook(saved_text).await; 1622 + trigger_auto_sync_if_enabled().await; 1291 1623 }); 1292 1624 1293 1625 Ok(()) ··· 1362 1694 1363 1695 println!("[Rust] Tagset saved successfully"); 1364 1696 1365 - // Push to webhook (fire and forget) 1366 - let saved_tagset = SavedTagset { 1367 - id, 1368 - tags, 1369 - saved_at: now, 1370 - metadata, 1371 - }; 1697 + // Trigger auto-sync if enabled (fire and forget) 1372 1698 tauri::async_runtime::spawn(async move { 1373 - push_tagset_to_webhook(saved_tagset).await; 1699 + trigger_auto_sync_if_enabled().await; 1374 1700 }); 1375 1701 1376 1702 Ok(()) ··· 1808 2134 } 1809 2135 1810 2136 println!("[Rust] Image saved successfully with id: {}", item_id); 2137 + 2138 + // Trigger auto-sync if enabled (fire and forget) 2139 + tauri::async_runtime::spawn(async move { 2140 + trigger_auto_sync_if_enabled().await; 2141 + }); 2142 + 1811 2143 Ok(item_id) 1812 2144 } 1813 2145 ··· 2021 2353 } 2022 2354 2023 2355 #[tauri::command] 2356 + fn quit_app() { 2357 + println!("[Rust] Quitting app for profile switch"); 2358 + std::process::exit(0); 2359 + } 2360 + 2361 + #[tauri::command] 2024 2362 async fn get_webhook_url() -> Result<Option<String>, String> { 2025 2363 let conn = get_connection()?; 2026 2364 ··· 2091 2429 } 2092 2430 2093 2431 #[tauri::command] 2432 + fn get_auto_sync() -> Result<bool, String> { 2433 + let conn = get_connection()?; 2434 + let enabled: Option<String> = conn 2435 + .query_row( 2436 + "SELECT value FROM settings WHERE key = 'auto_sync'", 2437 + [], 2438 + |row| row.get(0), 2439 + ) 2440 + .ok(); 2441 + 2442 + Ok(enabled.map(|v| v == "1" || v == "true").unwrap_or(false)) 2443 + } 2444 + 2445 + #[tauri::command] 2446 + fn set_auto_sync(enabled: bool) -> Result<(), String> { 2447 + let conn = get_connection()?; 2448 + conn.execute( 2449 + "INSERT OR REPLACE INTO settings (key, value) VALUES ('auto_sync', ?)", 2450 + params![if enabled { "1" } else { "0" }], 2451 + ) 2452 + .map_err(|e| format!("Failed to save auto-sync setting: {}", e))?; 2453 + 2454 + println!("[Rust] Auto-sync set to: {}", enabled); 2455 + Ok(()) 2456 + } 2457 + 2458 + /// Check if auto-sync is enabled 2459 + fn is_auto_sync_enabled() -> bool { 2460 + if let Ok(conn) = get_connection() { 2461 + let enabled: Option<String> = conn 2462 + .query_row( 2463 + "SELECT value FROM settings WHERE key = 'auto_sync'", 2464 + [], 2465 + |row| row.get(0), 2466 + ) 2467 + .ok(); 2468 + enabled.map(|v| v == "1" || v == "true").unwrap_or(false) 2469 + } else { 2470 + false 2471 + } 2472 + } 2473 + 2474 + /// Trigger sync if auto-sync is enabled and webhook is configured 2475 + async fn trigger_auto_sync_if_enabled() { 2476 + if !is_auto_sync_enabled() { 2477 + return; 2478 + } 2479 + 2480 + // Check if webhook is configured 2481 + let webhook_configured = if let Ok(conn) = get_connection() { 2482 + conn.query_row::<String, _, _>( 2483 + "SELECT value FROM settings WHERE key = 'webhook_url'", 2484 + [], 2485 + |row| row.get(0), 2486 + ).ok().map(|v| !v.is_empty()).unwrap_or(false) 2487 + } else { 2488 + false 2489 + }; 2490 + 2491 + if !webhook_configured { 2492 + println!("[Rust] Auto-sync: skipping, no webhook configured"); 2493 + return; 2494 + } 2495 + 2496 + println!("[Rust] Auto-sync: triggering sync after save"); 2497 + // Use sync_all for bidirectional sync 2498 + match sync_all_internal().await { 2499 + Ok(result) => { 2500 + println!("[Rust] Auto-sync completed: pulled={}, pushed={}", result.pulled, result.pushed); 2501 + } 2502 + Err(e) => { 2503 + println!("[Rust] Auto-sync failed: {}", e); 2504 + } 2505 + } 2506 + } 2507 + 2508 + #[tauri::command] 2094 2509 async fn sync_to_webhook() -> Result<SyncResult, String> { 2095 2510 println!("[Rust] sync_to_webhook called"); 2096 2511 2097 2512 // Get webhook URL and API key 2098 2513 let conn = get_connection()?; 2099 - let webhook_url: String = conn 2514 + let base_webhook_url: String = conn 2100 2515 .query_row( 2101 2516 "SELECT value FROM settings WHERE key = 'webhook_url'", 2102 2517 [], ··· 2104 2519 ) 2105 2520 .map_err(|_| "No webhook URL configured".to_string())?; 2106 2521 2107 - if webhook_url.is_empty() { 2522 + if base_webhook_url.is_empty() { 2108 2523 return Err("No webhook URL configured".to_string()); 2109 2524 } 2525 + 2526 + // Append profile to webhook URL 2527 + let webhook_url = append_profile_to_url(&base_webhook_url)?; 2110 2528 2111 2529 let api_key: Option<String> = conn 2112 2530 .query_row( ··· 2187 2605 Ok(last_sync) 2188 2606 } 2189 2607 2608 + /// Profile info returned to the UI 2609 + #[derive(Debug, Serialize, Deserialize)] 2610 + struct ProfileInfo { 2611 + current_profile: String, 2612 + default_profile: String, 2613 + is_production_build: bool, 2614 + profiles: Vec<ProfileEntry>, 2615 + } 2616 + 2617 + /// Get current profile information including all available profiles 2618 + #[tauri::command] 2619 + fn get_profile_info() -> Result<ProfileInfo, String> { 2620 + let config = load_profile_config(); 2621 + 2622 + Ok(ProfileInfo { 2623 + current_profile: config.current.clone(), 2624 + default_profile: get_default_profile(), 2625 + is_production_build: is_production_build(), 2626 + profiles: config.profiles, 2627 + }) 2628 + } 2629 + 2630 + /// Switch to a different profile 2631 + /// This changes both local database and sync target 2632 + /// Returns updated profile info 2633 + #[tauri::command] 2634 + fn set_profile(profile_slug: String) -> Result<ProfileInfo, String> { 2635 + let mut config = load_profile_config(); 2636 + 2637 + // Verify profile exists 2638 + if !config.profiles.iter().any(|p| p.slug == profile_slug) { 2639 + return Err(format!("Profile '{}' does not exist", profile_slug)); 2640 + } 2641 + 2642 + // Update current profile 2643 + config.current = profile_slug.clone(); 2644 + if !save_profile_config(&config) { 2645 + return Err("Failed to save profile config".to_string()); 2646 + } 2647 + 2648 + println!("[Rust] Switched to profile: {}", profile_slug); 2649 + 2650 + // Clear the database connection cache so next access uses new profile's DB 2651 + // Note: This requires the app to re-initialize database on next access 2652 + clear_db_cache(); 2653 + 2654 + get_profile_info() 2655 + } 2656 + 2657 + /// Create a new profile 2658 + #[tauri::command] 2659 + fn create_profile(name: String) -> Result<ProfileInfo, String> { 2660 + let mut config = load_profile_config(); 2661 + 2662 + // Generate slug from name 2663 + let slug = name.to_lowercase() 2664 + .chars() 2665 + .map(|c| if c.is_alphanumeric() { c } else { '-' }) 2666 + .collect::<String>() 2667 + .trim_matches('-') 2668 + .to_string(); 2669 + 2670 + if slug.is_empty() { 2671 + return Err("Invalid profile name".to_string()); 2672 + } 2673 + 2674 + // Check for duplicate 2675 + if config.profiles.iter().any(|p| p.slug == slug) { 2676 + return Err(format!("Profile '{}' already exists", slug)); 2677 + } 2678 + 2679 + // Add new profile 2680 + config.profiles.push(ProfileEntry { 2681 + slug: slug.clone(), 2682 + name: name.clone(), 2683 + }); 2684 + 2685 + if !save_profile_config(&config) { 2686 + return Err("Failed to save profile config".to_string()); 2687 + } 2688 + 2689 + println!("[Rust] Created profile: {} ({})", name, slug); 2690 + get_profile_info() 2691 + } 2692 + 2693 + /// Delete a profile (cannot delete current or default profiles) 2694 + #[tauri::command] 2695 + fn delete_profile(profile_slug: String) -> Result<ProfileInfo, String> { 2696 + let mut config = load_profile_config(); 2697 + let default = get_default_profile(); 2698 + 2699 + // Cannot delete current profile 2700 + if config.current == profile_slug { 2701 + return Err("Cannot delete the current profile. Switch to another profile first.".to_string()); 2702 + } 2703 + 2704 + // Cannot delete default profile 2705 + if profile_slug == default || profile_slug == "default" || profile_slug == "dev" { 2706 + return Err("Cannot delete built-in profiles".to_string()); 2707 + } 2708 + 2709 + // Remove profile 2710 + let original_len = config.profiles.len(); 2711 + config.profiles.retain(|p| p.slug != profile_slug); 2712 + 2713 + if config.profiles.len() == original_len { 2714 + return Err(format!("Profile '{}' not found", profile_slug)); 2715 + } 2716 + 2717 + if !save_profile_config(&config) { 2718 + return Err("Failed to save profile config".to_string()); 2719 + } 2720 + 2721 + // Note: We don't delete the database file - user can manually remove it 2722 + println!("[Rust] Deleted profile: {}", profile_slug); 2723 + get_profile_info() 2724 + } 2725 + 2726 + /// Reset to auto-detected default profile 2727 + #[tauri::command] 2728 + fn reset_profile_to_default() -> Result<ProfileInfo, String> { 2729 + let default = get_default_profile(); 2730 + set_profile(default) 2731 + } 2732 + 2733 + /// Clear database cache to force re-initialization on profile switch 2734 + fn clear_db_cache() { 2735 + // The DB_INIT Once guard can't be reset, but we can work around this 2736 + // by tracking the current profile in a separate variable 2737 + // For now, profile switch will require app restart for full isolation 2738 + // This matches desktop behavior where profile switch restarts the app 2739 + println!("[Rust] Note: Full profile switch requires app restart for complete database isolation"); 2740 + } 2741 + 2190 2742 #[tauri::command] 2191 2743 async fn auto_sync_if_needed() -> Result<Option<SyncResult>, String> { 2192 2744 // Check if webhook URL is configured ··· 2502 3054 2503 3055 drop(conn); // Close connection before async call 2504 3056 2505 - // Build request URL 2506 - let items_url = if let Some(ref sync_time) = last_sync { 3057 + // Build request URL with profile parameter 3058 + let base_url = if let Some(ref sync_time) = last_sync { 2507 3059 // Incremental sync - get items since last sync 2508 3060 format!("{}/items/since/{}", server_url.trim_end_matches('/'), sync_time) 2509 3061 } else { 2510 3062 // Full sync - get all items 2511 3063 format!("{}/items", server_url.trim_end_matches('/')) 2512 3064 }; 3065 + let items_url = append_profile_to_url(&base_url)?; 2513 3066 2514 3067 println!("[Rust] Pulling from: {}", items_url); 2515 3068 ··· 2615 3168 } 2616 3169 2617 3170 let client = reqwest::Client::new(); 2618 - let post_url = format!("{}/items", server_url.trim_end_matches('/')); 3171 + let base_post_url = format!("{}/items", server_url.trim_end_matches('/')); 3172 + let post_url = append_profile_to_url(&base_post_url)?; 2619 3173 let mut pushed = 0; 2620 3174 let mut failed = 0; 2621 3175 ··· 2703 3257 2704 3258 /// Full bidirectional sync: pull then push 2705 3259 #[tauri::command] 2706 - async fn sync_all() -> Result<BidirectionalSyncResult, String> { 2707 - println!("[Rust] sync_all called"); 2708 - 3260 + /// Internal sync_all implementation (called by both command and auto-sync) 3261 + async fn sync_all_internal() -> Result<BidirectionalSyncResult, String> { 2709 3262 // Pull first 2710 3263 let pull_result = pull_from_server().await?; 2711 3264 ··· 2733 3286 conflicts: total_conflicts, 2734 3287 message: format!("Synced: {} pulled, {} pushed", total_pulled, total_pushed), 2735 3288 }) 3289 + } 3290 + 3291 + #[tauri::command] 3292 + async fn sync_all() -> Result<BidirectionalSyncResult, String> { 3293 + println!("[Rust] sync_all called"); 3294 + sync_all_internal().await 2736 3295 } 2737 3296 2738 3297 /// Get current sync status ··· 2827 3386 set_webhook_url, 2828 3387 get_webhook_api_key, 2829 3388 set_webhook_api_key, 3389 + get_auto_sync, 3390 + set_auto_sync, 2830 3391 sync_to_webhook, 2831 3392 get_last_sync, 2832 3393 auto_sync_if_needed, 3394 + // Profile management 3395 + get_profile_info, 3396 + set_profile, 3397 + create_profile, 3398 + delete_profile, 3399 + reset_profile_to_default, 3400 + quit_app, 2833 3401 // Bidirectional sync 2834 3402 pull_from_server, 2835 3403 push_to_server, ··· 2838 3406 // Legacy/deprecated 2839 3407 get_shared_url, 2840 3408 // Debug 2841 - debug_list_container_files 3409 + debug_list_container_files, 3410 + debug_query_database, 3411 + debug_export_database 2842 3412 ]) 2843 3413 .run(tauri::generate_context!()) 2844 3414 .expect("error while running tauri application");
+332
backend/tauri-mobile/src/App.css
··· 998 998 background: white; 999 999 border-radius: 12px; 1000 1000 padding: 1.25rem; 1001 + margin-bottom: 1rem; 1001 1002 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 1003 + } 1004 + 1005 + .settings-section:last-child { 1006 + margin-bottom: 0; 1002 1007 } 1003 1008 1004 1009 .settings-section h2 { ··· 1012 1017 color: #666; 1013 1018 margin: 0 0 1rem 0; 1014 1019 line-height: 1.4; 1020 + } 1021 + 1022 + /* Profile banner shown in main view when not on default profile */ 1023 + .profile-banner { 1024 + background: linear-gradient(135deg, #ff9500 0%, #ff6b00 100%); 1025 + color: white; 1026 + text-align: center; 1027 + padding: 0.35rem 1rem; 1028 + font-size: 0.8rem; 1029 + font-weight: 600; 1030 + letter-spacing: 0.5px; 1031 + text-transform: uppercase; 1032 + } 1033 + 1034 + /* Profile warning in settings */ 1035 + .profile-warning-banner { 1036 + background: #fff3cd; 1037 + color: #856404; 1038 + border: 1px solid #ffeeba; 1039 + border-radius: 8px; 1040 + padding: 0.75rem; 1041 + margin-bottom: 1rem; 1042 + font-size: 0.85rem; 1043 + line-height: 1.4; 1044 + } 1045 + 1046 + body.dark .profile-warning-banner { 1047 + background: #473a14; 1048 + color: #ffc107; 1049 + border-color: #5c4a1a; 1050 + } 1051 + 1052 + /* Profile selector */ 1053 + .profile-selector { 1054 + margin-bottom: 1rem; 1055 + } 1056 + 1057 + .profile-selector label { 1058 + display: block; 1059 + font-size: 0.9rem; 1060 + color: #666; 1061 + margin-bottom: 0.5rem; 1062 + } 1063 + 1064 + body.dark .profile-selector label { 1065 + color: #999; 1066 + } 1067 + 1068 + .profile-input-row { 1069 + display: flex; 1070 + gap: 0.5rem; 1071 + margin-bottom: 0.75rem; 1072 + } 1073 + 1074 + .profile-input-row input { 1075 + flex: 1; 1076 + padding: 0.75rem; 1077 + border: 1px solid #ddd; 1078 + border-radius: 12px; 1079 + font-size: 16px; 1080 + } 1081 + 1082 + body.dark .profile-input-row input { 1083 + background: #2a2a2a; 1084 + border-color: #444; 1085 + color: #f0f0f0; 1086 + } 1087 + 1088 + .profile-quick-buttons { 1089 + display: flex; 1090 + flex-wrap: wrap; 1091 + gap: 0.5rem; 1092 + } 1093 + 1094 + .profile-btn { 1095 + padding: 0.5rem 1rem; 1096 + border: 1px solid #ddd; 1097 + border-radius: 20px; 1098 + background: #f0f0f0; 1099 + color: #333; 1100 + font-size: 0.85rem; 1101 + cursor: pointer; 1102 + transition: all 0.2s ease; 1103 + } 1104 + 1105 + .profile-btn:active { 1106 + transform: scale(0.95); 1107 + } 1108 + 1109 + .profile-btn.active { 1110 + background: #007aff; 1111 + border-color: #007aff; 1112 + color: white; 1113 + } 1114 + 1115 + .profile-btn.reset { 1116 + background: transparent; 1117 + border-color: #999; 1118 + color: #666; 1119 + font-style: italic; 1120 + } 1121 + 1122 + body.dark .profile-btn { 1123 + background: #2a2a2a; 1124 + border-color: #444; 1125 + color: #ccc; 1126 + } 1127 + 1128 + body.dark .profile-btn.active { 1129 + background: #0a84ff; 1130 + border-color: #0a84ff; 1131 + color: white; 1132 + } 1133 + 1134 + body.dark .profile-btn.reset { 1135 + background: transparent; 1136 + border-color: #666; 1137 + color: #999; 1138 + } 1139 + 1140 + /* Profile List (desktop-style) */ 1141 + .profile-list { 1142 + display: flex; 1143 + flex-direction: column; 1144 + gap: 0.5rem; 1145 + margin-bottom: 1rem; 1146 + } 1147 + 1148 + .profile-item { 1149 + display: flex; 1150 + align-items: center; 1151 + justify-content: space-between; 1152 + padding: 0.75rem 1rem; 1153 + background: #f8f8f8; 1154 + border: 1px solid #e0e0e0; 1155 + border-radius: 10px; 1156 + transition: all 0.2s ease; 1157 + } 1158 + 1159 + .profile-item.active { 1160 + background: #e8f4ff; 1161 + border-color: #007aff; 1162 + } 1163 + 1164 + body.dark .profile-item { 1165 + background: #2a2a2a; 1166 + border-color: #444; 1167 + } 1168 + 1169 + body.dark .profile-item.active { 1170 + background: #1a3a5c; 1171 + border-color: #0a84ff; 1172 + } 1173 + 1174 + .profile-radio-label { 1175 + display: flex; 1176 + align-items: center; 1177 + gap: 0.75rem; 1178 + flex: 1; 1179 + cursor: pointer; 1180 + } 1181 + 1182 + .profile-radio-label input[type="radio"] { 1183 + width: 20px; 1184 + height: 20px; 1185 + cursor: pointer; 1186 + } 1187 + 1188 + .profile-name { 1189 + font-weight: 500; 1190 + color: #333; 1191 + } 1192 + 1193 + body.dark .profile-name { 1194 + color: #f0f0f0; 1195 + } 1196 + 1197 + .profile-badge { 1198 + font-size: 0.7rem; 1199 + padding: 2px 6px; 1200 + border-radius: 4px; 1201 + text-transform: uppercase; 1202 + font-weight: 600; 1203 + } 1204 + 1205 + .profile-badge.builtin { 1206 + background: #e0e0e0; 1207 + color: #666; 1208 + } 1209 + 1210 + .profile-badge.current { 1211 + background: #22c55e; 1212 + color: white; 1213 + } 1214 + 1215 + body.dark .profile-badge.builtin { 1216 + background: #3a3a3a; 1217 + color: #999; 1218 + } 1219 + 1220 + .profile-delete-btn { 1221 + padding: 4px 10px; 1222 + font-size: 0.8rem; 1223 + background: transparent; 1224 + border: 1px solid #ef4444; 1225 + border-radius: 6px; 1226 + color: #ef4444; 1227 + cursor: pointer; 1228 + } 1229 + 1230 + .profile-delete-btn:active { 1231 + background: #ef4444; 1232 + color: white; 1233 + } 1234 + 1235 + .profile-add-section { 1236 + margin-top: 1rem; 1237 + padding-top: 1rem; 1238 + border-top: 1px solid #e0e0e0; 1239 + } 1240 + 1241 + body.dark .profile-add-section { 1242 + border-top-color: #444; 1015 1243 } 1016 1244 1017 1245 .webhook-input { ··· 2577 2805 body.dark .toast-error { 2578 2806 background: #ff453a; 2579 2807 } 2808 + 2809 + /* Modal overlay and content */ 2810 + .modal-overlay { 2811 + position: fixed; 2812 + top: 0; 2813 + left: 0; 2814 + right: 0; 2815 + bottom: 0; 2816 + background: rgba(0, 0, 0, 0.5); 2817 + display: flex; 2818 + align-items: center; 2819 + justify-content: center; 2820 + z-index: 10000; 2821 + padding: 20px; 2822 + } 2823 + 2824 + .modal-content { 2825 + background: white; 2826 + border-radius: 16px; 2827 + padding: 24px; 2828 + max-width: 320px; 2829 + width: 100%; 2830 + text-align: center; 2831 + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); 2832 + } 2833 + 2834 + body.dark .modal-content { 2835 + background: #2a2a2a; 2836 + color: #f0f0f0; 2837 + } 2838 + 2839 + .modal-content h3 { 2840 + margin: 0 0 12px 0; 2841 + font-size: 1.2rem; 2842 + } 2843 + 2844 + .modal-content p { 2845 + margin: 0 0 16px 0; 2846 + font-size: 0.95rem; 2847 + color: #666; 2848 + line-height: 1.5; 2849 + } 2850 + 2851 + body.dark .modal-content p { 2852 + color: #aaa; 2853 + } 2854 + 2855 + .modal-buttons { 2856 + display: flex; 2857 + gap: 12px; 2858 + justify-content: center; 2859 + margin-top: 20px; 2860 + } 2861 + 2862 + .modal-btn { 2863 + padding: 12px 24px; 2864 + border-radius: 10px; 2865 + font-size: 1rem; 2866 + font-weight: 500; 2867 + cursor: pointer; 2868 + border: none; 2869 + min-width: 100px; 2870 + } 2871 + 2872 + .modal-btn.primary { 2873 + background: #007aff; 2874 + color: white; 2875 + } 2876 + 2877 + .modal-btn.secondary { 2878 + background: #e5e5e5; 2879 + color: #333; 2880 + } 2881 + 2882 + body.dark .modal-btn.secondary { 2883 + background: #3a3a3a; 2884 + color: #ccc; 2885 + } 2886 + 2887 + .modal-btn:active { 2888 + transform: scale(0.95); 2889 + } 2890 + 2891 + /* Auto-sync toggle */ 2892 + .auto-sync-toggle { 2893 + display: flex; 2894 + align-items: center; 2895 + gap: 0.75rem; 2896 + padding: 0.75rem 0; 2897 + margin: 0.5rem 0; 2898 + cursor: pointer; 2899 + font-size: 0.9rem; 2900 + color: #333; 2901 + } 2902 + 2903 + body.dark .auto-sync-toggle { 2904 + color: #ccc; 2905 + } 2906 + 2907 + .auto-sync-toggle input[type="checkbox"] { 2908 + width: 20px; 2909 + height: 20px; 2910 + cursor: pointer; 2911 + }
+291 -13
backend/tauri-mobile/src/App.tsx
··· 66 66 pending_count: number; 67 67 } 68 68 69 + interface ProfileEntry { 70 + slug: string; 71 + name: string; 72 + } 73 + 74 + interface ProfileInfo { 75 + current_profile: string; 76 + default_profile: string; 77 + is_production_build: boolean; 78 + profiles: ProfileEntry[]; 79 + } 80 + 69 81 // Unified item for combined list 70 82 interface UnifiedItem { 71 83 id: string; ··· 212 224 const [webhookUrlInput, setWebhookUrlInput] = useState(""); 213 225 const [webhookApiKey, setWebhookApiKey] = useState(""); 214 226 const [webhookApiKeyInput, setWebhookApiKeyInput] = useState(""); 227 + const [autoSyncEnabled, setAutoSyncEnabled] = useState(false); 215 228 const [showApiKey, setShowApiKey] = useState(false); 216 229 const [isSyncing, setIsSyncing] = useState(false); 217 230 const [syncMessage, setSyncMessage] = useState<string | null>(null); 218 231 const [lastSync, setLastSync] = useState<string | null>(null); 219 232 const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null); 233 + const [profileInfo, setProfileInfo] = useState<ProfileInfo | null>(null); 234 + const [profileInput, setProfileInput] = useState(""); 235 + const [showRestartPrompt, setShowRestartPrompt] = useState(false); 220 236 221 237 // Keyboard height for iOS keyboard avoidance 222 238 const keyboardHeight = useKeyboardHeight(); ··· 286 302 loadData(); 287 303 loadWebhookUrl(); 288 304 loadWebhookApiKey(); 305 + loadAutoSync(); 289 306 loadLastSync(); 290 307 loadSyncStatus(); 308 + loadProfileInfo(); 291 309 tryAutoSync(); 292 310 293 311 // Reload when app comes back to foreground (to pick up items from share extension) ··· 341 359 } 342 360 } catch (error) { 343 361 console.error("Failed to load webhook API key:", error); 362 + } 363 + }; 364 + 365 + const loadAutoSync = async () => { 366 + try { 367 + const enabled = await invoke<boolean>("get_auto_sync"); 368 + setAutoSyncEnabled(enabled); 369 + } catch (error) { 370 + console.error("Failed to load auto-sync setting:", error); 371 + } 372 + }; 373 + 374 + const toggleAutoSync = async (enabled: boolean) => { 375 + try { 376 + await invoke("set_auto_sync", { enabled }); 377 + setAutoSyncEnabled(enabled); 378 + } catch (error) { 379 + console.error("Failed to set auto-sync:", error); 344 380 } 345 381 }; 346 382 ··· 362 398 } 363 399 } catch (error) { 364 400 console.error("Failed to load sync status:", error); 401 + } 402 + }; 403 + 404 + const loadProfileInfo = async () => { 405 + try { 406 + const info = await invoke<ProfileInfo>("get_profile_info"); 407 + setProfileInfo(info); 408 + setProfileInput(info.current_profile); 409 + } catch (error) { 410 + console.error("Failed to load profile info:", error); 411 + } 412 + }; 413 + 414 + const setProfile = async (profileSlug: string) => { 415 + console.log(`[Profile] setProfile called with: ${profileSlug}`); 416 + try { 417 + console.log(`[Profile] Invoking set_profile command...`); 418 + const info = await invoke<ProfileInfo>("set_profile", { profileSlug }); 419 + console.log(`[Profile] set_profile returned:`, info); 420 + setProfileInfo(info); 421 + setProfileInput(info.current_profile); 422 + // Show restart prompt for full database isolation 423 + setShowRestartPrompt(true); 424 + } catch (error) { 425 + console.error("[Profile] Failed to set profile:", error); 426 + setSyncMessage(`Failed: ${error}`); 427 + setTimeout(() => setSyncMessage(null), 3000); 428 + } 429 + }; 430 + 431 + const quitApp = async () => { 432 + try { 433 + await invoke("quit_app"); 434 + } catch (error) { 435 + console.error("Failed to quit:", error); 365 436 } 366 437 }; 367 438 ··· 2100 2171 </header> 2101 2172 2102 2173 <main className="settings-view"> 2174 + {/* Server Sync Section */} 2103 2175 <div className="settings-section"> 2104 2176 <h2>Server Sync</h2> 2105 2177 <p className="settings-description"> 2106 2178 Sync your saved items with the server. Pull to get items from other devices, push to send local items, or sync all to do both. 2179 + </p> 2180 + <p className="settings-description" style={{ fontSize: "0.85rem", opacity: 0.8 }}> 2181 + Items sync to your account's "{profileInfo?.current_profile}" profile on the server. 2107 2182 </p> 2108 2183 2109 2184 <div className="webhook-input"> ··· 2153 2228 Save Settings 2154 2229 </button> 2155 2230 2231 + <label className="auto-sync-toggle"> 2232 + <input 2233 + type="checkbox" 2234 + checked={autoSyncEnabled} 2235 + onChange={(e) => toggleAutoSync(e.target.checked)} 2236 + /> 2237 + <span>Auto-sync when items are added or modified</span> 2238 + </label> 2239 + 2156 2240 {lastSync && ( 2157 2241 <p className="last-sync-info"> 2158 2242 Last synced: {new Date(lastSync).toLocaleString()} ··· 2197 2281 )} 2198 2282 </div> 2199 2283 2284 + {/* Profile Section */} 2285 + <div className="settings-section"> 2286 + <h2>Profiles</h2> 2287 + {profileInfo && ( 2288 + <> 2289 + <p className="settings-description"> 2290 + {profileInfo.is_production_build 2291 + ? "App Store/TestFlight build" 2292 + : "Development build"}. 2293 + Each profile has separate local data and sync destination. 2294 + </p> 2295 + 2296 + {profileInfo.current_profile !== "default" && ( 2297 + <div className="profile-warning-banner"> 2298 + Using "{profileInfo.current_profile}" profile - data is isolated from default 2299 + </div> 2300 + )} 2301 + 2302 + {/* Profile List */} 2303 + <div className="profile-list"> 2304 + {profileInfo.profiles.map((profile) => { 2305 + const isCurrent = profile.slug === profileInfo.current_profile; 2306 + const isBuiltin = profile.slug === "default" || profile.slug === "dev"; 2307 + const canDelete = !isCurrent && !isBuiltin; 2308 + 2309 + return ( 2310 + <div key={profile.slug} className={`profile-item ${isCurrent ? "active" : ""}`}> 2311 + <label className="profile-radio-label"> 2312 + <input 2313 + type="radio" 2314 + name="profile" 2315 + checked={isCurrent} 2316 + onChange={() => { 2317 + console.log(`[Profile] Radio clicked: ${profile.slug}, isCurrent: ${isCurrent}`); 2318 + if (!isCurrent) { 2319 + console.log(`[Profile] Switching to: ${profile.slug}`); 2320 + setProfile(profile.slug); 2321 + } 2322 + }} 2323 + /> 2324 + <span className="profile-name">{profile.name}</span> 2325 + {isBuiltin && <span className="profile-badge builtin">built-in</span>} 2326 + {isCurrent && <span className="profile-badge current">active</span>} 2327 + </label> 2328 + {canDelete && ( 2329 + <button 2330 + className="profile-delete-btn" 2331 + onClick={async () => { 2332 + if (confirm(`Delete profile "${profile.name}"?\n\nThe database file will be preserved.`)) { 2333 + try { 2334 + const info = await invoke<ProfileInfo>("delete_profile", { profileSlug: profile.slug }); 2335 + setProfileInfo(info); 2336 + } catch (err) { 2337 + alert(`Failed to delete: ${err}`); 2338 + } 2339 + } 2340 + }} 2341 + > 2342 + Delete 2343 + </button> 2344 + )} 2345 + </div> 2346 + ); 2347 + })} 2348 + </div> 2349 + 2350 + {/* Add Profile */} 2351 + <div className="profile-add-section"> 2352 + <div className="profile-input-row"> 2353 + <input 2354 + type="text" 2355 + value={profileInput} 2356 + onChange={(e) => setProfileInput(e.target.value)} 2357 + placeholder="New profile name" 2358 + /> 2359 + <button 2360 + className="sync-btn secondary" 2361 + onClick={async () => { 2362 + if (profileInput.trim()) { 2363 + try { 2364 + const info = await invoke<ProfileInfo>("create_profile", { name: profileInput.trim() }); 2365 + setProfileInfo(info); 2366 + setProfileInput(""); 2367 + } catch (err) { 2368 + alert(`Failed to create: ${err}`); 2369 + } 2370 + } 2371 + }} 2372 + disabled={!profileInput.trim()} 2373 + > 2374 + Add 2375 + </button> 2376 + </div> 2377 + </div> 2378 + 2379 + {/* Reset to default */} 2380 + {profileInfo.current_profile !== profileInfo.default_profile && ( 2381 + <button 2382 + className="profile-btn reset" 2383 + onClick={() => setProfile(profileInfo.default_profile)} 2384 + style={{ marginTop: "12px" }} 2385 + > 2386 + Reset to {profileInfo.default_profile} (auto-detected) 2387 + </button> 2388 + )} 2389 + </> 2390 + )} 2391 + </div> 2392 + 2393 + {/* Debug Section */} 2200 2394 <div className="settings-section"> 2201 2395 <h2>Debug</h2> 2202 - <button 2203 - className="sync-btn secondary" 2204 - onClick={async () => { 2205 - try { 2206 - const files = await invoke<string[]>("debug_list_container_files"); 2207 - alert(files.join("\n")); 2208 - } catch (e) { 2209 - alert("Error: " + e); 2210 - } 2211 - }} 2212 - > 2213 - List Container Files 2214 - </button> 2396 + <div className="sync-btn-row" style={{ flexDirection: 'column', gap: '0.5rem' }}> 2397 + <button 2398 + className="sync-btn secondary" 2399 + onClick={async () => { 2400 + try { 2401 + const files = await invoke<string[]>("debug_list_container_files"); 2402 + setSyncMessage("FILES: " + files.join(" | ")); 2403 + } catch (e) { 2404 + setSyncMessage("ERROR: " + String(e)); 2405 + } 2406 + }} 2407 + > 2408 + List Container Files 2409 + </button> 2410 + <button 2411 + className="sync-btn secondary" 2412 + onClick={async () => { 2413 + try { 2414 + const info = await invoke<string>("debug_query_database"); 2415 + setSyncMessage(info); 2416 + } catch (e) { 2417 + setSyncMessage("DB ERROR: " + String(e)); 2418 + } 2419 + }} 2420 + > 2421 + Query Database 2422 + </button> 2423 + <button 2424 + className="sync-btn secondary" 2425 + onClick={async () => { 2426 + try { 2427 + setSyncMessage("Exporting..."); 2428 + const base64 = await invoke<string>("debug_export_database"); 2429 + setSyncMessage("DB_START>>>" + base64.substring(0, 500) + "...(" + base64.length + " total chars). Use AirDrop or copy manually."); 2430 + console.log("FULL_DB_BASE64:", base64); 2431 + } catch (e) { 2432 + setSyncMessage("EXPORT ERROR: " + String(e)); 2433 + } 2434 + }} 2435 + > 2436 + Export Database 2437 + </button> 2438 + </div> 2215 2439 </div> 2440 + 2216 2441 </main> 2442 + 2443 + {/* Restart prompt modal */} 2444 + {showRestartPrompt && ( 2445 + <div className="modal-overlay"> 2446 + <div className="modal-content"> 2447 + <h3>Profile Changed</h3> 2448 + <p> 2449 + Switched to <strong>{profileInfo?.current_profile}</strong> profile. 2450 + </p> 2451 + <p> 2452 + Please restart the app to ensure complete data isolation. 2453 + </p> 2454 + <div className="modal-buttons"> 2455 + <button className="modal-btn secondary" onClick={() => setShowRestartPrompt(false)}> 2456 + Later 2457 + </button> 2458 + <button className="modal-btn primary" onClick={quitApp}> 2459 + Quit Now 2460 + </button> 2461 + </div> 2462 + </div> 2463 + </div> 2464 + )} 2217 2465 </div> 2218 2466 ); 2219 2467 } ··· 2401 2649 </button> 2402 2650 </header> 2403 2651 2652 + {/* Profile warning banner when not on default profile */} 2653 + {profileInfo && profileInfo.current_profile !== "default" && ( 2654 + <div className="profile-banner"> 2655 + Profile: {profileInfo.current_profile} 2656 + </div> 2657 + )} 2658 + 2404 2659 {viewModeActive ? ( 2405 2660 renderViewMode() 2406 2661 ) : ( ··· 2555 2810 {toast && ( 2556 2811 <div className={`toast toast-${toast.type}`}> 2557 2812 {toast.message} 2813 + </div> 2814 + )} 2815 + 2816 + {/* Restart prompt modal */} 2817 + {showRestartPrompt && ( 2818 + <div className="modal-overlay"> 2819 + <div className="modal-content"> 2820 + <h3>Profile Changed</h3> 2821 + <p> 2822 + Switched to <strong>{profileInfo?.current_profile}</strong> profile. 2823 + </p> 2824 + <p> 2825 + Please restart the app to ensure complete data isolation. 2826 + </p> 2827 + <div className="modal-buttons"> 2828 + <button className="modal-btn secondary" onClick={() => setShowRestartPrompt(false)}> 2829 + Later 2830 + </button> 2831 + <button className="modal-btn primary" onClick={quitApp}> 2832 + Quit Now 2833 + </button> 2834 + </div> 2835 + </div> 2558 2836 </div> 2559 2837 )} 2560 2838 </div>
+163 -1
docs/profiles.md
··· 458 458 2. **No prompt for already-selected profile** - Radio button behavior (change event only fires on actual change) 459 459 3. **API keys stored in plaintext** - In local SQLite file (consider encryption for future) 460 460 461 + ## Mobile Profile Support (iOS) 462 + 463 + Mobile profiles provide dev/production isolation for iOS builds. 464 + 465 + ### How It Works 466 + 467 + **Auto-Detection via App Store Receipt:** 468 + 469 + ``` 470 + App Store / TestFlight build → has receipt → "default" profile 471 + Xcode install (dev/test) → no receipt → "dev" profile 472 + ``` 473 + 474 + **Local Data Isolation:** 475 + 476 + Each profile uses a separate database file: 477 + ``` 478 + App Group Container/ 479 + ├── peek-default.db # TestFlight/App Store data 480 + ├── peek-dev.db # Xcode dev builds 481 + └── peek-test.db # If user switches to "test" profile 482 + ``` 483 + 484 + **Sync Isolation:** 485 + 486 + All sync URLs include `?profile={slug}`: 487 + ``` 488 + GET /items?profile=dev 489 + POST /items?profile=dev 490 + ``` 491 + 492 + ### Architecture 493 + 494 + ``` 495 + ┌─────────────────────────────────────────────────────────┐ 496 + │ iOS App │ 497 + ├─────────────────────────────────────────────────────────┤ 498 + │ Build Detection (is_app_store_build) │ 499 + │ ├── Has receipt? → "default" profile │ 500 + │ └── No receipt? → "dev" profile │ 501 + ├─────────────────────────────────────────────────────────┤ 502 + │ Local Storage │ Server Sync │ 503 + │ ─────────────────────────────┼────────────────────────│ 504 + │ peek-{profile}.db │ ?profile={profile} │ 505 + │ (auto-detect only) │ (can override) │ 506 + └─────────────────────────────────────────────────────────┘ 507 + ``` 508 + 509 + ### Key Design Decisions 510 + 511 + **1. Local DB uses auto-detected profile only** 512 + 513 + The database filename is determined by build type, not user settings: 514 + - Prevents accidentally using wrong database 515 + - Xcode builds ALWAYS use `peek-dev.db` 516 + - TestFlight/App Store ALWAYS use `peek-default.db` 517 + 518 + **2. Sync profile can be overridden** 519 + 520 + User can change sync profile in Settings for testing scenarios: 521 + - Default: auto-detected (matches local DB) 522 + - Override: any profile name (for advanced testing) 523 + 524 + **3. Same bundle ID, different data** 525 + 526 + Both dev and production use `com.dietrich.peek-mobile`: 527 + - Installing one replaces the other 528 + - App Group container persists between installs 529 + - Each install type uses its own database file 530 + 531 + ### Settings UI 532 + 533 + The Settings screen shows: 534 + - Current profile (auto-detected or overridden) 535 + - Build type indicator (production/development) 536 + - Quick switch buttons: default, dev, test 537 + - "Reset to auto" button if overridden 538 + - Visual warning banner when not on "default" 539 + 540 + ### Trade-offs and Alternatives Considered 541 + 542 + **Current Implementation: Per-Profile Database Files** 543 + 544 + ``` 545 + Pros: 546 + ✓ Full local data isolation 547 + ✓ Simple implementation 548 + ✓ No bundle ID changes needed 549 + ✓ Works with single App Group 550 + 551 + Cons: 552 + ✗ Can't run dev and prod simultaneously (same bundle ID) 553 + ✗ Switching installs replaces the app 554 + ``` 555 + 556 + **Alternative 1: Different Bundle IDs** 557 + 558 + ``` 559 + com.dietrich.peek-mobile # Production 560 + com.dietrich.peek-mobile-dev # Development 561 + 562 + Pros: 563 + ✓ True coexistence (both installed at once) 564 + ✓ Complete isolation (separate App Groups) 565 + ✓ Different app icons possible 566 + 567 + Cons: 568 + ✗ Requires separate provisioning profiles 569 + ✗ Two apps in App Store Connect 570 + ✗ More build configuration complexity 571 + ✗ Share extension would need separate handling 572 + ``` 573 + 574 + **Alternative 2: Shared Database, Sync-Only Isolation** 575 + 576 + ``` 577 + Single peek.db for all builds 578 + Only sync URLs include ?profile= 579 + 580 + Pros: 581 + ✓ Simplest implementation 582 + ✓ Shared local data (could be useful) 583 + 584 + Cons: 585 + ✗ Test data pollutes production view 586 + ✗ Easy to accidentally sync test data to prod server 587 + ✗ No local isolation (the problem we had) 588 + ``` 589 + 590 + **Alternative 3: User-Selectable Local Profile** 591 + 592 + ``` 593 + Settings allows changing local DB profile 594 + peek-{user-selected}.db 595 + 596 + Pros: 597 + ✓ Maximum flexibility 598 + ✓ User can create arbitrary profiles 599 + 600 + Cons: 601 + ✗ Easy to accidentally use wrong profile 602 + ✗ Complex state management 603 + ✗ Profile mismatch between local/sync possible 604 + ``` 605 + 606 + ### Files 607 + 608 + **Implementation:** 609 + - `src-tauri/AppGroupBridge.m` - `is_app_store_build()` receipt detection 610 + - `src-tauri/src/lib.rs`: 611 + - `get_default_profile()` - auto-detect based on build type 612 + - `get_db_path()` - profile-specific database filename 613 + - `get_current_profile_slug()` - sync profile (with override support) 614 + - `append_profile_to_url()` - adds ?profile= to sync URLs 615 + - `get_profile_info` / `set_profile` - Tauri commands 616 + - `src/App.tsx` - Profile section in Settings UI 617 + - `src/App.css` - Profile banner and selector styles 618 + 619 + **Build Scripts:** 620 + - `npm run rebuild:ios` - Clean rebuild for simulator 621 + - `npm run rebuild:ios:release` - Clean rebuild for device 622 + 461 623 ## Future Enhancements 462 624 463 625 - Profile import/export functionality 464 626 - Profile templates (pre-configured profiles) 465 627 - Profile-level theme settings 466 628 - Profile backup/restore 467 - - Mobile profile support (Tauri) 629 + - ~~Mobile profile support (Tauri)~~ ✓ Implemented 468 630 - Profile encryption at rest 469 631 - Profile-specific extension configurations 470 632 - Profile migration between machines