1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct MiniAppHistoryEntry {
18 pub id: i64,
19 pub name: String,
20 pub src_url: String,
21 pub attachment_ref: String,
22 pub open_count: i64,
23 pub last_opened_at: i64,
24 pub is_favorite: bool,
25 pub categories: String,
27 pub marketplace_id: Option<String>,
29 pub installed_version: Option<String>,
31}
32
33pub fn record_miniapp_opened(
36 name: String,
37 src_url: String,
38 attachment_ref: String,
39) -> Result<(), String> {
40 record_miniapp_opened_with_metadata(name, src_url, attachment_ref, String::new(), None, None)
41}
42
43pub fn record_miniapp_opened_with_metadata(
45 name: String,
46 src_url: String,
47 attachment_ref: String,
48 categories: String,
49 marketplace_id: Option<String>,
50 installed_version: Option<String>,
51) -> Result<(), String> {
52 let conn = super::get_write_connection_guard_static()?;
53
54 let now = web_time::SystemTime::now()
55 .duration_since(web_time::UNIX_EPOCH)
56 .unwrap()
57 .as_secs() as i64;
58
59 conn.execute(
62 r#"
63 INSERT INTO miniapps_history (name, src_url, attachment_ref, open_count, last_opened_at, categories, marketplace_id, installed_version)
64 VALUES (?1, ?2, ?3, 1, ?4, ?5, ?6, ?7)
65 ON CONFLICT(name) DO UPDATE SET
66 src_url = excluded.src_url,
67 attachment_ref = excluded.attachment_ref,
68 open_count = open_count + 1,
69 last_opened_at = excluded.last_opened_at,
70 categories = CASE WHEN excluded.categories != '' THEN excluded.categories ELSE categories END,
71 marketplace_id = CASE WHEN excluded.marketplace_id IS NOT NULL THEN excluded.marketplace_id ELSE marketplace_id END,
72 installed_version = CASE WHEN excluded.installed_version IS NOT NULL THEN excluded.installed_version ELSE installed_version END
73 "#,
74 rusqlite::params![name, src_url, attachment_ref, now, categories, marketplace_id, installed_version],
75 ).map_err(|e| format!("Failed to record Mini App opened: {}", e))?;
76
77
78 Ok(())
79}
80
81pub fn get_miniapps_history(
83 limit: Option<i64>,
84) -> Result<Vec<MiniAppHistoryEntry>, String> {
85 let conn = super::get_db_connection_guard_static()?;
86
87 let limit_val = limit.unwrap_or(50);
88
89 let mut stmt = conn.prepare(
90 r#"
91 SELECT id, name, src_url, attachment_ref, open_count, last_opened_at, is_favorite, categories, marketplace_id, installed_version
92 FROM miniapps_history
93 ORDER BY is_favorite DESC, last_opened_at DESC
94 LIMIT ?1
95 "#
96 ).map_err(|e| format!("Failed to prepare Mini Apps history query: {}", e))?;
97
98 let result: Vec<MiniAppHistoryEntry> = {
99 let entries = stmt.query_map(rusqlite::params![limit_val], |row| {
100 Ok(MiniAppHistoryEntry {
101 id: row.get(0)?,
102 name: row.get(1)?,
103 src_url: row.get(2)?,
104 attachment_ref: row.get(3)?,
105 open_count: row.get(4)?,
106 last_opened_at: row.get(5)?,
107 is_favorite: row.get::<_, i64>(6)? != 0,
108 categories: row.get(7)?,
109 marketplace_id: row.get(8)?,
110 installed_version: row.get(9)?,
111 })
112 }).map_err(|e| format!("Failed to query Mini Apps history: {}", e))?;
113
114 entries.filter_map(|e| e.ok()).collect()
115 };
116
117 Ok(result)
118}
119
120pub fn toggle_miniapp_favorite(
122 id: i64,
123) -> Result<bool, String> {
124 let conn = super::get_write_connection_guard_static()?;
125
126 conn.execute(
128 "UPDATE miniapps_history SET is_favorite = NOT is_favorite WHERE id = ?1",
129 rusqlite::params![id],
130 ).map_err(|e| format!("Failed to toggle Mini App favorite: {}", e))?;
131
132 let new_state: bool = conn.query_row(
134 "SELECT is_favorite FROM miniapps_history WHERE id = ?1",
135 rusqlite::params![id],
136 |row| row.get::<_, i64>(0).map(|v| v != 0)
137 ).map_err(|e| format!("Failed to get Mini App favorite state: {}", e))?;
138
139
140 Ok(new_state)
141}
142
143pub fn set_miniapp_favorite(
145 id: i64,
146 is_favorite: bool,
147) -> Result<(), String> {
148 let conn = super::get_write_connection_guard_static()?;
149
150 conn.execute(
151 "UPDATE miniapps_history SET is_favorite = ?1 WHERE id = ?2",
152 rusqlite::params![if is_favorite { 1 } else { 0 }, id],
153 ).map_err(|e| format!("Failed to set Mini App favorite: {}", e))?;
154
155
156 Ok(())
157}
158
159pub fn remove_miniapp_from_history(
161 name: &str,
162) -> Result<(), String> {
163 let conn = super::get_write_connection_guard_static()?;
164
165 conn.execute(
166 "DELETE FROM miniapps_history WHERE name = ?1",
167 rusqlite::params![name],
168 ).map_err(|e| format!("Failed to remove Mini App from history: {}", e))?;
169
170
171 Ok(())
172}
173
174#[cfg(not(target_arch = "wasm32"))]
178pub fn prune_dangling_miniapp_history() -> Result<u32, String> {
179 let conn = super::get_write_connection_guard_static()?;
180
181 let rows: Vec<(i64, Option<String>)> = {
182 let mut stmt = conn.prepare(
183 "SELECT id, attachment_ref FROM miniapps_history"
184 ).map_err(|e| format!("Failed to query Mini Apps history: {}", e))?;
185 let mapped = stmt.query_map([], |row| {
186 Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?))
187 }).map_err(|e| format!("Failed to iterate Mini Apps history: {}", e))?;
188 mapped.filter_map(|r| r.ok()).collect()
189 };
190
191 let mut removed = 0u32;
192 for (id, path) in rows {
193 let Some(path) = path else { continue };
195 if path.is_empty() {
196 continue;
197 }
198 if !matches!(std::path::Path::new(&path).try_exists(), Ok(false)) {
201 continue;
202 }
203 conn.execute(
204 "DELETE FROM miniapps_history WHERE id = ?1",
205 rusqlite::params![id],
206 ).map_err(|e| format!("Failed to prune Mini App history entry: {}", e))?;
207 removed += 1;
208 }
209
210 Ok(removed)
211}
212
213#[cfg(target_arch = "wasm32")]
215pub fn prune_dangling_miniapp_history() -> Result<u32, String> {
216 Ok(0)
217}
218
219pub fn update_miniapp_version(
221 marketplace_id: &str,
222 version: &str,
223) -> Result<(), String> {
224 let conn = super::get_write_connection_guard_static()?;
225
226 conn.execute(
227 "UPDATE miniapps_history SET installed_version = ?1 WHERE marketplace_id = ?2",
228 rusqlite::params![version, marketplace_id],
229 ).map_err(|e| format!("Failed to update Mini App version: {}", e))?;
230
231
232 Ok(())
233}
234
235pub fn get_miniapp_installed_version(
237 marketplace_id: &str,
238) -> Result<Option<String>, String> {
239 let conn = super::get_db_connection_guard_static()?;
240
241 let result = conn.query_row(
242 "SELECT installed_version FROM miniapps_history WHERE marketplace_id = ?1",
243 rusqlite::params![marketplace_id],
244 |row| row.get::<_, Option<String>>(0)
245 );
246
247
248
249 match result {
250 Ok(version) => Ok(version),
251 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
252 Err(e) => Err(format!("Failed to get Mini App installed version: {}", e)),
253 }
254}
255
256pub fn backfill_marketplace_ids(apps: &[crate::webxdc::MarketplaceApp]) -> Result<u32, String> {
261 let conn = super::get_write_connection_guard_static()?;
262
263 let mut stmt = conn.prepare(
265 "SELECT id, src_url FROM miniapps_history WHERE marketplace_id IS NULL"
266 ).map_err(|e| format!("Failed to query orphan history: {}", e))?;
267
268 let orphans: Vec<(i64, String)> = stmt.query_map([], |row| {
269 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
270 }).map_err(|e| format!("Failed to iterate orphans: {}", e))?
271 .filter_map(|r| r.ok())
272 .collect();
273
274 let mut updated = 0u32;
275 for (id, src_url) in &orphans {
276 let hash = src_url
278 .rsplit('/')
279 .next()
280 .and_then(|f| f.strip_suffix(".xdc"));
281 let hash = match hash {
282 Some(h) => h,
283 None => continue,
284 };
285
286 if let Some(app) = apps.iter().find(|a| a.blossom_hash == hash) {
288 conn.execute(
289 "UPDATE miniapps_history SET marketplace_id = ?1, installed_version = ?2 WHERE id = ?3",
290 rusqlite::params![app.id, app.version, id],
291 ).map_err(|e| format!("Failed to backfill marketplace_id: {}", e))?;
292 updated += 1;
293 }
294 }
295
296 Ok(updated)
297}
298
299pub fn get_miniapp_granted_permissions(
306 file_hash: &str,
307) -> Result<String, String> {
308 let conn = super::get_db_connection_guard_static()?;
309
310 let mut stmt = conn.prepare(
311 "SELECT permission FROM miniapp_permissions WHERE file_hash = ?1 AND granted = 1"
312 ).map_err(|e| format!("Failed to prepare permission query: {}", e))?;
313
314 let permissions: Vec<String> = stmt.query_map(rusqlite::params![file_hash], |row| {
315 row.get::<_, String>(0)
316 })
317 .map_err(|e| format!("Failed to query permissions: {}", e))?
318 .filter_map(|r| r.ok())
319 .collect();
320
321 Ok(permissions.join(","))
322}
323
324pub fn set_miniapp_permission(
326 file_hash: &str,
327 permission: &str,
328 granted: bool,
329) -> Result<(), String> {
330 let conn = super::get_write_connection_guard_static()?;
331
332 let now = web_time::SystemTime::now()
333 .duration_since(web_time::UNIX_EPOCH)
334 .unwrap()
335 .as_secs() as i64;
336
337 conn.execute(
338 r#"
339 INSERT INTO miniapp_permissions (file_hash, permission, granted, granted_at)
340 VALUES (?1, ?2, ?3, ?4)
341 ON CONFLICT(file_hash, permission) DO UPDATE SET
342 granted = excluded.granted,
343 granted_at = CASE WHEN excluded.granted = 1 THEN excluded.granted_at ELSE granted_at END
344 "#,
345 rusqlite::params![file_hash, permission, granted as i32, if granted { Some(now) } else { None::<i64> }],
346 ).map_err(|e| format!("Failed to set Mini App permission: {}", e))?;
347
348
349 Ok(())
350}
351
352pub fn set_miniapp_permissions(
355 file_hash: &str,
356 permissions: &[(&str, bool)],
357) -> Result<(), String> {
358 let mut conn = super::get_write_connection_guard_static()?;
359
360 let now = web_time::SystemTime::now()
361 .duration_since(web_time::UNIX_EPOCH)
362 .unwrap()
363 .as_secs() as i64;
364
365 let tx = conn.transaction()
366 .map_err(|e| format!("Failed to start transaction: {}", e))?;
367
368 for (permission, granted) in permissions {
369 tx.execute(
370 r#"
371 INSERT INTO miniapp_permissions (file_hash, permission, granted, granted_at)
372 VALUES (?1, ?2, ?3, ?4)
373 ON CONFLICT(file_hash, permission) DO UPDATE SET
374 granted = excluded.granted,
375 granted_at = CASE WHEN excluded.granted = 1 THEN excluded.granted_at ELSE granted_at END
376 "#,
377 rusqlite::params![file_hash, permission, *granted as i32, if *granted { Some(now) } else { None::<i64> }],
378 ).map_err(|e| format!("Failed to set Mini App permission: {}", e))?;
379 }
380
381 tx.commit()
382 .map_err(|e| format!("Failed to commit permission changes: {}", e))?;
383
384 Ok(())
385}
386
387pub fn has_miniapp_permission_prompt(
390 file_hash: &str,
391) -> Result<bool, String> {
392 let conn = super::get_db_connection_guard_static()?;
393
394 let exists: bool = conn.query_row(
395 "SELECT EXISTS(SELECT 1 FROM miniapp_permissions WHERE file_hash = ?1)",
396 rusqlite::params![file_hash],
397 |row| row.get(0)
398 ).map_err(|e| format!("Failed to check permission prompt: {}", e))?;
399
400
401 Ok(exists)
402}
403
404pub fn revoke_all_miniapp_permissions(
406 file_hash: &str,
407) -> Result<(), String> {
408 let conn = super::get_write_connection_guard_static()?;
409
410 conn.execute(
411 "DELETE FROM miniapp_permissions WHERE file_hash = ?1",
412 rusqlite::params![file_hash],
413 ).map_err(|e| format!("Failed to revoke Mini App permissions: {}", e))?;
414
415
416 Ok(())
417}
418
419pub fn save_marketplace_cache(apps: &[crate::webxdc::MarketplaceApp]) -> Result<(), String> {
426 let mut conn = super::get_write_connection_guard_static()?;
427
428 let now = web_time::SystemTime::now()
429 .duration_since(web_time::UNIX_EPOCH)
430 .unwrap()
431 .as_secs() as i64;
432
433 let tx = conn.transaction()
434 .map_err(|e| format!("Failed to start marketplace cache transaction: {}", e))?;
435
436 for app in apps {
438 let json = serde_json::to_string(app)
439 .map_err(|e| format!("Failed to serialize marketplace app {}: {}", app.id, e))?;
440 tx.execute(
441 "INSERT OR REPLACE INTO marketplace_cache (id, data, fetched_at) VALUES (?1, ?2, ?3)",
442 rusqlite::params![app.id, json, now],
443 ).map_err(|e| format!("Failed to upsert marketplace app {}: {}", app.id, e))?;
444 }
445
446 if !apps.is_empty() {
448 let placeholders: Vec<String> = (1..=apps.len()).map(|i| format!("?{}", i)).collect();
449 let sql = format!(
450 "DELETE FROM marketplace_cache WHERE id NOT IN ({})",
451 placeholders.join(",")
452 );
453 let ids: Vec<&str> = apps.iter().map(|a| a.id.as_str()).collect();
454 let params: Vec<&dyn rusqlite::types::ToSql> = ids.iter().map(|s| s as &dyn rusqlite::types::ToSql).collect();
455 tx.execute(&sql, params.as_slice())
456 .map_err(|e| format!("Failed to clean stale marketplace cache entries: {}", e))?;
457 }
458
459 tx.commit()
460 .map_err(|e| format!("Failed to commit marketplace cache: {}", e))?;
461
462 Ok(())
463}
464
465pub fn load_marketplace_cache() -> Result<Vec<crate::webxdc::MarketplaceApp>, String> {
467 let conn = super::get_db_connection_guard_static()?;
468
469 let mut stmt = conn.prepare(
470 "SELECT data FROM marketplace_cache"
471 ).map_err(|e| format!("Failed to prepare marketplace cache query: {}", e))?;
472
473 let apps: Vec<crate::webxdc::MarketplaceApp> = stmt.query_map([], |row| {
474 let json: String = row.get(0)?;
475 serde_json::from_str(&json).map_err(|e| {
476 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
477 })
478 })
479 .map_err(|e| format!("Failed to query marketplace cache: {}", e))?
480 .filter_map(|r| r.ok())
481 .collect();
482
483 Ok(apps)
484}
485
486pub fn copy_miniapp_permissions(
488 old_hash: &str,
489 new_hash: &str,
490) -> Result<(), String> {
491 let conn = super::get_write_connection_guard_static()?;
492
493 conn.execute(
495 r#"
496 INSERT OR REPLACE INTO miniapp_permissions (file_hash, permission, granted, granted_at)
497 SELECT ?2, permission, granted, granted_at
498 FROM miniapp_permissions
499 WHERE file_hash = ?1
500 "#,
501 rusqlite::params![old_hash, new_hash],
502 ).map_err(|e| format!("Failed to copy Mini App permissions: {}", e))?;
503
504
505 Ok(())
506}
507
508pub struct PeerAdvertisementRecord {
514 pub npub: String,
515 pub node_addr_encoded: String,
516}
517
518pub fn get_active_peer_advertisements(
524 topic_encoded: &str,
525 my_npub: &str,
526) -> Result<Vec<PeerAdvertisementRecord>, String> {
527 let conn = super::get_db_connection_guard_static()?;
528
529 let mut stmt = conn.prepare(
534 r#"
535 SELECT e.npub, e.tags
536 FROM events e
537 INNER JOIN (
538 SELECT npub, MAX(created_at) as max_ts
539 FROM events
540 WHERE kind = 30078
541 AND reference_id = ?1
542 AND content = 'peer-advertisement'
543 AND npub IS NOT NULL
544 AND npub != ?2
545 GROUP BY npub
546 ) latest ON e.npub = latest.npub AND e.created_at = latest.max_ts
547 WHERE e.kind = 30078
548 AND e.reference_id = ?1
549 AND e.content = 'peer-advertisement'
550 AND NOT EXISTS (
551 SELECT 1 FROM events l
552 WHERE l.kind = 30078
553 AND l.reference_id = ?1
554 AND l.npub = e.npub
555 AND l.content = 'peer-left'
556 AND l.created_at >= e.created_at
557 )
558 "#
559 ).map_err(|e| format!("Failed to prepare peer advertisement query: {}", e))?;
560
561 let records = stmt.query_map(
562 rusqlite::params![topic_encoded, my_npub],
563 |row| {
564 let npub: String = row.get(0)?;
565 let tags_json: String = row.get(1)?;
566
567 let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
569 let node_addr = tags.iter()
570 .find(|t| t.first().map(|s| s.as_str()) == Some("webxdc-node-addr"))
571 .and_then(|t| t.get(1))
572 .cloned()
573 .unwrap_or_default();
574
575 Ok(PeerAdvertisementRecord {
576 npub,
577 node_addr_encoded: node_addr,
578 })
579 }
580 ).map_err(|e| format!("Failed to query peer advertisements: {}", e))?;
581
582 Ok(records.filter_map(|r| r.ok()).filter(|r| !r.node_addr_encoded.is_empty()).collect())
583}
584
585pub fn get_active_peer_advertisements_in(
590 topic_encoded: &str,
591 chat_identifier: &str,
592 my_npub: &str,
593 limit: usize,
594) -> Result<Vec<PeerAdvertisementRecord>, String> {
595 let Ok(chat) = super::id_cache::get_chat_id_by_identifier(chat_identifier) else {
596 return Ok(Vec::new());
597 };
598 let conn = super::get_db_connection_guard_static()?;
599 let mut stmt = conn.prepare(
600 r#"
601 SELECT e.npub, e.tags
602 FROM events e
603 INNER JOIN (
604 SELECT npub, MAX(created_at) as max_ts
605 FROM events
606 WHERE kind = 30078 AND reference_id = ?1 AND chat_id = ?3
607 AND content = 'peer-advertisement' AND npub IS NOT NULL AND npub != ?2
608 GROUP BY npub
609 ) latest ON e.npub = latest.npub AND e.created_at = latest.max_ts
610 WHERE e.kind = 30078 AND e.reference_id = ?1 AND e.chat_id = ?3
611 AND e.content = 'peer-advertisement'
612 AND NOT EXISTS (
613 SELECT 1 FROM events l
614 WHERE l.kind = 30078 AND l.reference_id = ?1 AND l.chat_id = ?3
615 AND l.npub = e.npub AND l.content = 'peer-left' AND l.created_at >= e.created_at
616 )
617 ORDER BY e.created_at DESC
618 LIMIT ?4
619 "#,
620 ).map_err(|e| format!("Failed to prepare peer advertisement query: {}", e))?;
621 let records = stmt.query_map(rusqlite::params![topic_encoded, my_npub, chat, limit as i64], |row| {
622 let npub: String = row.get(0)?;
623 let tags: Vec<Vec<String>> = serde_json::from_str(&row.get::<_, String>(1)?).unwrap_or_default();
624 let node_addr_encoded = tags
625 .iter()
626 .find(|t| t.first().map(|s| s.as_str()) == Some("webxdc-node-addr"))
627 .and_then(|t| t.get(1))
628 .cloned()
629 .unwrap_or_default();
630 Ok(PeerAdvertisementRecord { npub, node_addr_encoded })
631 }).map_err(|e| format!("Failed to query peer advertisements: {}", e))?;
632 Ok(records.filter_map(|r| r.ok()).filter(|r| !r.node_addr_encoded.is_empty()).collect())
633}
634
635pub fn peer_signal_is_current(
641 topic_encoded: &str,
642 npub: &str,
643 created_at: u64,
644 is_advertisement: bool,
645) -> Result<bool, String> {
646 let conn = super::get_db_connection_guard_static()?;
647 let sql = if is_advertisement {
650 r#"SELECT EXISTS(
651 SELECT 1 FROM events
652 WHERE kind = 30078 AND reference_id = ?1 AND npub = ?2
653 AND ((content IN ('peer-advertisement','peer-left') AND created_at > ?3)
654 OR (content = 'peer-left' AND created_at = ?3))
655 )"#
656 } else {
657 r#"SELECT EXISTS(
658 SELECT 1 FROM events
659 WHERE kind = 30078 AND reference_id = ?1 AND npub = ?2
660 AND content = 'peer-advertisement' AND created_at > ?3
661 )"#
662 };
663 let superseded: bool = conn
664 .query_row(sql, rusqlite::params![topic_encoded, npub, created_at as i64], |row| row.get(0))
665 .map_err(|e| format!("Failed to check peer signal currency: {}", e))?;
666 Ok(!superseded)
667}