lingxia-lxapp 0.18.0

LxApp (lightweight application) container and runtime for LingXia framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
//! Cache and resolution for lxapp registry records — the app's name, icon,
//! description, status, and permissions as the server owns them.
//!
//! Permissions ride the same record because they are the same kind of fact,
//! keyed the same way and wanted at the same moment: the pre-open status gate
//! already fetches this record, so a guest's grant costs no request of its own.
//!
//! Separate from the update path on purpose. A name or icon changes without any
//! package changing, and the sidebar has to draw apps that were never
//! installed, neither of which the update check can express: it is scoped to an
//! OTA-managed target and answers `None` for "already up to date".
//!
//! Icons are content-addressed, so an unchanged icon costs nothing after the
//! first fetch and the same artwork reached through two URLs is one file.
//! Names are not: a string that short is cheaper to re-fetch than to reconcile.

use super::metadata::{self, RegistryRecord};
use super::metadata::{StoredGrant, StoredPermissions};
use super::runtime_registry;
use crate::archive;
use crate::error::LxAppError;
use crate::provider::{
    LxAppChannel, LxAppPermissions, LxAppRegistryInfo, LxAppRegistryRequest, LxAppStatus,
    lxapp_registry_provider,
};
use lingxia_platform::traits::app_runtime::AppRuntime;
use rong_rt::download as service_executor;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

/// How long a cached name/icon is served without a background refresh. Artwork
/// is not time-critical; the cost of being a day late is nil.
const LISTING_TTL: Duration = Duration::from_secs(24 * 60 * 60);

/// How long a cached status counts as a current answer. Far shorter than the
/// listing TTL: a day-old "published" for a suspended app is an incident.
const STATUS_TTL: Duration = Duration::from_secs(15 * 60);

/// Ceiling on the pre-open status check. Opening an app must not wait on a slow
/// network — past this we fall back to the standing local permission.
const OPEN_GATE_TIMEOUT: Duration = Duration::from_secs(3);

/// Whole-request ceiling for one icon body. Without it a stalled response hangs
/// the fetch forever, and a detached refresh holds its dedupe slot for the life
/// of the process.
const ICON_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);

/// Floor between refresh attempts for the same app. The sidebar asks on every
/// layout pass, and a fast-failing provider (offline, connection refused)
/// releases the in-flight guard immediately — without this, a window drag turns
/// into a request per frame.
const REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(60);

/// Age past which a leftover download staging file is swept. Cancellation drops
/// the download future without running its cleanup, so some always leak.
const STAGING_MAX_AGE: Duration = Duration::from_secs(60 * 60);

const ICONS_DIR: &str = "icons";
const STAGING_SUFFIX: &str = ".part";

fn now_secs() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|elapsed| elapsed.as_secs() as i64)
        .unwrap_or(0)
}

/// Path to the icon cache without touching the filesystem. Resolution runs on
/// the layout path, where a `create_dir_all` per sidebar row would be pure
/// overhead; only the download path needs the directory to exist.
fn icons_dir() -> Option<PathBuf> {
    let runtime = runtime_registry::get_platform()?;
    Some(
        runtime
            .app_cache_dir()
            .join(super::LINGXIA_DIR)
            .join(super::LXAPPS_DIR)
            .join(ICONS_DIR),
    )
}

fn ensure_icons_dir() -> Option<PathBuf> {
    let dir = icons_dir()?;
    if let Err(err) = fs::create_dir_all(&dir) {
        crate::warn!("Failed to create lxapp icon cache dir: {}", err);
        return None;
    }
    Some(dir)
}

/// Keep the extension the server used where it is a plausible image, so the
/// cached file stays loadable by path alone.
fn icon_extension(url: &str) -> &'static str {
    let path = url.split(&['?', '#'][..]).next().unwrap_or(url);
    let ext = path
        .rsplit('/')
        .next()
        .and_then(|segment| segment.rsplit_once('.'))
        .map(|(_, ext)| ext.to_ascii_lowercase());
    match ext.as_deref() {
        Some("svg") => "svg",
        Some("jpg") | Some("jpeg") => "jpg",
        Some("webp") => "webp",
        Some("ico") => "ico",
        _ => "png",
    }
}

fn active_refreshes() -> &'static Mutex<HashSet<String>> {
    static ACTIVE: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
    ACTIVE.get_or_init(|| Mutex::new(HashSet::new()))
}

/// Marks a background refresh in flight and releases it on drop, so a sidebar
/// that repaints ten times does not issue ten identical fetches.
///
/// Only the background path takes it. The pre-open gate deliberately does not:
/// losing this race there would resolve to "no answer", and "no answer" means
/// the open is allowed — two simultaneous opens of a suspended app would let
/// one through.
struct RefreshGuard(String);

impl RefreshGuard {
    fn acquire(key: String) -> Option<Self> {
        let mut active = active_refreshes()
            .lock()
            .unwrap_or_else(|err| err.into_inner());
        active.insert(key.clone()).then(|| Self(key))
    }
}

impl Drop for RefreshGuard {
    fn drop(&mut self) {
        if let Ok(mut active) = active_refreshes().lock() {
            active.remove(&self.0);
        }
    }
}

/// Last refresh attempt per app, successful or not. Separate from the
/// record's `fetched_at`, which only advances on an answer.
fn last_attempts() -> &'static Mutex<HashMap<String, Instant>> {
    static ATTEMPTS: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
    ATTEMPTS.get_or_init(|| Mutex::new(HashMap::new()))
}

fn attempted_recently(appid: &str) -> bool {
    let attempts = last_attempts()
        .lock()
        .unwrap_or_else(|err| err.into_inner());
    attempts
        .get(appid)
        .is_some_and(|at| at.elapsed() < REFRESH_RETRY_INTERVAL)
}

fn mark_attempted(appid: &str) {
    last_attempts()
        .lock()
        .unwrap_or_else(|err| err.into_inner())
        .insert(appid.to_string(), Instant::now());
}

type RegistryChangeListener = Box<dyn Fn(&[String]) + Send + Sync>;

fn change_listener() -> &'static Mutex<Option<RegistryChangeListener>> {
    static LISTENER: OnceLock<Mutex<Option<RegistryChangeListener>>> = OnceLock::new();
    LISTENER.get_or_init(|| Mutex::new(None))
}

/// Install the hook a host uses to repaint after a refresh lands.
///
/// Refreshes are asynchronous and nothing else observes them: a sidebar that
/// asked for a refresh while painting has already finished painting by the time
/// the answer arrives, so without this the new name or icon waits for whatever
/// unrelated event next triggers a relayout.
pub fn set_registry_change_listener(listener: RegistryChangeListener) {
    *change_listener().lock().unwrap_or_else(|e| e.into_inner()) = Some(listener);
}

/// Status that blocked an open, when the error came from [`ensure_open_allowed`].
pub fn registry_unavailable_status(error: &LxAppError) -> Option<LxAppStatus> {
    let LxAppError::RongJSHost {
        data: Some(data), ..
    } = error
    else {
        return None;
    };
    let code = data.get("code")?.as_str()?;
    let status = LxAppStatus::from_str_lossy(code);
    status.blocks_open().then_some(status)
}

type OpenBlockedListener = Box<dyn Fn(LxAppStatus) + Send + Sync>;

fn open_blocked_listener() -> &'static Mutex<Option<OpenBlockedListener>> {
    static LISTENER: OnceLock<Mutex<Option<OpenBlockedListener>>> = OnceLock::new();
    LISTENER.get_or_init(|| Mutex::new(None))
}

/// Host chrome registers this to show a notice when an open is refused with
/// no JS `catch` (pin, App Link, panel).
pub fn set_open_blocked_listener(listener: OpenBlockedListener) {
    *open_blocked_listener()
        .lock()
        .unwrap_or_else(|e| e.into_inner()) = Some(listener);
}

/// Tell host chrome a blocked open happened. Logs when nothing is registered.
pub fn notify_open_blocked(error: &LxAppError) {
    if let Some(status) = registry_unavailable_status(error) {
        let guard = open_blocked_listener()
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        if let Some(listener) = guard.as_ref() {
            listener(status);
            return;
        }
    }
    crate::warn!("lxapp open blocked: {}", error);
}

fn notify_changed(appids: &[String]) {
    if appids.is_empty() {
        return;
    }
    let guard = change_listener().lock().unwrap_or_else(|e| e.into_inner());
    if let Some(listener) = guard.as_ref() {
        listener(appids);
    }
}

fn record(appid: &str) -> Option<RegistryRecord> {
    metadata::registry_get(appid).ok().flatten()
}

/// How long a chrome lookup may reuse a record without touching redb.
const RECORD_CACHE_TTL: Duration = Duration::from_secs(2);

struct CachedRecord {
    read_at: Instant,
    record: Option<RegistryRecord>,
}

fn record_cache() -> &'static Mutex<HashMap<String, CachedRecord>> {
    static CACHE: OnceLock<Mutex<HashMap<String, CachedRecord>>> = OnceLock::new();
    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

/// Record for chrome paths (names, icons). Sidebar and switcher rebuilds ask
/// for the same app many times per click; each answer is a redb read plus a
/// JSON decode, so serve repeats from memory. Returns whether this call hit
/// the store, which is also the moment `ensure_fresh` re-checks disk state.
fn cached_record(appid: &str) -> (Option<RegistryRecord>, bool) {
    let mut cache = record_cache().lock().unwrap_or_else(|err| err.into_inner());
    if let Some(entry) = cache.get(appid)
        && entry.read_at.elapsed() < RECORD_CACHE_TTL
    {
        return (entry.record.clone(), false);
    }
    let record = record(appid);
    cache.insert(
        appid.to_string(),
        CachedRecord {
            read_at: Instant::now(),
            record: record.clone(),
        },
    );
    (record, true)
}

fn invalidate_record(appid: &str) {
    record_cache()
        .lock()
        .unwrap_or_else(|err| err.into_inner())
        .remove(appid);
}

/// A record written with a clock that was ahead reads as "aged negative
/// seconds". Treat it as expired rather than as eternally fresh — otherwise one
/// bad clock pins a stale `Published` past every later suspension.
fn is_expired(record: &RegistryRecord, ttl: Duration) -> bool {
    stamp_is_expired(record.fetched_at, ttl)
}

fn stamp_is_expired(fetched_at: i64, ttl: Duration) -> bool {
    let now = now_secs();
    now < fetched_at || now - fetched_at > ttl.as_secs() as i64
}

/// A record naming artwork that is no longer on disk. Records live in the data
/// directory and icons in the cache directory, so the OS can drop the artwork
/// and leave the record behind — and that record still looks fresh, which would
/// otherwise mean up to a full listing TTL with no icon and no attempt to get
/// one back.
fn cached_icon_is_gone(record: &RegistryRecord, icons_dir: &Path) -> bool {
    record
        .icon_file
        .as_ref()
        .is_some_and(|file| !icons_dir.join(file).exists())
}

/// A listing without a name is not done — `lxapp.json` is only a fallback, and
/// a 404 / empty answer cached for the full TTL would hide a later rename.
/// `attempted_recently` still floors retries at a minute.
fn listing_needs_refresh(cached: &RegistryRecord) -> bool {
    cached
        .name
        .as_deref()
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .is_none()
        || is_expired(cached, LISTING_TTL)
}

/// The registry's name for this app.
pub(crate) fn name(appid: &str) -> Option<String> {
    lxapp_registry_provider()?;
    cached_record(appid)
        .0
        .and_then(|record| record.name)
        .filter(|name| !name.trim().is_empty())
}

/// Absolute path to the cached icon, or `None` when nothing is cached yet.
/// Callers fall back to a host-drawn default mark from here.
pub(crate) fn icon_path(appid: &str) -> Option<String> {
    lxapp_registry_provider()?;
    let file = cached_record(appid).0.and_then(|record| record.icon_file)?;
    let path = icons_dir()?.join(file);
    path.exists().then(|| path.to_string_lossy().into_owned())
}

pub(crate) fn status(appid: &str) -> LxAppStatus {
    if lxapp_registry_provider().is_none() {
        return LxAppStatus::Unknown;
    }
    record(appid)
        .map(|record| LxAppStatus::from_str_lossy(&record.status))
        .unwrap_or_default()
}

/// Refresh in the background when the cache is missing or past its TTL.
/// The sidebar calls this as it populates; nothing waits on the result.
pub(crate) fn ensure_fresh<S: AsRef<str>>(appids: &[S]) {
    if lxapp_registry_provider().is_none() {
        return;
    }
    let icons_dir = icons_dir();
    for appid in appids {
        let appid = appid.as_ref();
        if appid.trim().is_empty() {
            continue;
        }
        // A record served from memory was checked against disk within
        // `RECORD_CACHE_TTL`; only a store read re-runs the TTL and icon test.
        let (cached, from_store) = cached_record(appid);
        if !from_store {
            continue;
        }
        let needs_fetch = match (&cached, &icons_dir) {
            (None, _) => true,
            (Some(cached), Some(icons_dir)) => {
                listing_needs_refresh(cached) || cached_icon_is_gone(cached, icons_dir)
            }
            (Some(cached), None) => listing_needs_refresh(cached),
        };
        if !needs_fetch || attempted_recently(appid) {
            continue;
        }
        mark_attempted(appid);
        let appid = appid.to_string();
        std::mem::drop(crate::executor::spawn(Box::pin(async move {
            let Some(_guard) = RefreshGuard::acquire(appid.clone()) else {
                return;
            };
            match fetch_records(&appid, crate::default_channel().into()).await {
                Ok(Some(info)) => fetch_icons(&appid, &info).await,
                Ok(None) => {}
                Err(err) => {
                    crate::warn!("lxapp registry refresh failed: {}", err);
                }
            }
        })));
    }
}

/// Gate on a *fresh negative* answer only.
///
/// A stale `Published` is not evidence that an app is still permitted, so we
/// re-check when the cached status has aged out. But a check that cannot reach
/// the registry is not evidence of anything either, and an installed app must
/// keep opening offline — so only a status we just confirmed can block.
pub async fn ensure_open_allowed(appid: &str) -> Result<(), LxAppError> {
    if lxapp_registry_provider().is_none() {
        return Ok(());
    }
    let fresh = record(appid)
        .filter(|record| !is_expired(record, STATUS_TTL))
        .map(|record| LxAppStatus::from_str_lossy(&record.status));

    let status = match fresh {
        Some(status) => status,
        None => {
            let channel = crate::default_channel().into();
            match tokio::time::timeout(OPEN_GATE_TIMEOUT, fetch_records(appid, channel)).await {
                Ok(Ok(info)) => {
                    // Artwork is fetched outside the deadline: it is not what
                    // the gate is waiting for, and awaiting it here would let a
                    // slow image expire a check that already had its answer.
                    if let Some(info) = info.clone() {
                        let appid = appid.to_string();
                        std::mem::drop(crate::executor::spawn(Box::pin(async move {
                            fetch_icons(&appid, &info).await;
                        })));
                    }
                    info.map(|info| info.status).unwrap_or_default()
                }
                // Unreachable registry keeps the standing local permission.
                Ok(Err(err)) => {
                    crate::warn!("Registry status check failed for {}: {}", appid, err)
                        .with_appid(appid);
                    return Ok(());
                }
                Err(_) => {
                    crate::warn!("Registry status check timed out for {}", appid).with_appid(appid);
                    return Ok(());
                }
            }
        }
    };

    if status.blocks_open() {
        return Err(unavailable_error(appid, status));
    }
    Ok(())
}

/// The refusal a blocked open reports, typed so a caller can act on it.
///
/// `maintain` and `suspended` both stop the open and mean opposite things to a
/// user — "come back later" against "this is not yours to open" — so the status
/// travels in `data.code` rather than only inside a sentence. Callers branch on
/// it the way they branch on a surface error code; the message is a fallback for
/// a host that shows the raw text.
fn unavailable_error(appid: &str, status: LxAppStatus) -> LxAppError {
    LxAppError::RongJSHost {
        code: "3000".to_string(),
        message: format!("lxapp {appid} is {status} and cannot be opened"),
        data: Some(serde_json::json!({
            "bizCode": 3000,
            "code": status.as_str(),
            "appId": appid,
        })),
    }
}

/// Fetch the registry's answer for one channel and store it. Artwork is *not*
/// fetched here.
///
/// The status is the gating fact, and [`ensure_open_allowed`] bounds this call
/// with a deadline it fails open on. If an icon body were awaited inside that
/// deadline, a slow image would expire a check that had already been told the
/// app is suspended — and the drop would cancel the download too, so the next
/// attempt would be no faster.
pub(crate) async fn fetch_records(
    appid: &str,
    channel: LxAppChannel,
) -> Result<Option<LxAppRegistryInfo>, LxAppError> {
    let Some(provider) = lxapp_registry_provider() else {
        return Ok(None);
    };
    if appid.trim().is_empty() {
        return Ok(None);
    }

    let info = provider
        .fetch_registry_info(LxAppRegistryRequest::new(appid, channel))
        .await
        .map_err(|err| crate::provider::provider_error_to_lxapp_error(&err))?;
    mark_attempted(appid);

    let previous = record(appid);
    // An app the registry does not know is cached as `Unknown` rather than left
    // absent. Without this every open of a dev project, an unpublished app,
    // or a bundled builtin pays a fresh round trip — and a record that once
    // said `suspended` would keep saying so after the registry stopped
    // listing the app.
    let stored = RegistryRecord {
        appid: appid.to_string(),
        name: info.as_ref().and_then(|info| info.name.clone()),
        description: info.as_ref().and_then(|info| info.description.clone()),
        icon_url: info.as_ref().and_then(|info| info.icon_url.clone()),
        // Artwork is replaced by `fetch_icons`. Keep the previous file only
        // when the URL is unchanged: `resolve_icon_file` treats a stored file
        // as belonging to the stored URL, so carrying a file under a new URL
        // would skip the download. A withdrawn icon drops it. A 404 keeps
        // last artwork so an unknown app does not blank its icon.
        icon_file: carry_icon_file(info.as_ref(), previous.as_ref()),
        status: info
            .as_ref()
            .map(|info| info.status)
            .unwrap_or_default()
            .as_str()
            .to_string(),
        // Only this channel's answer is replaced. A 404 is a registry that has
        // no policy for this app, not a lost grant: it stores as "no
        // constraint" like any other absent grant.
        grants: carry_grants(info.as_ref(), previous.as_ref(), channel),
        fetched_at: now_secs(),
    };
    if let Err(err) = metadata::registry_upsert(&stored) {
        crate::warn!("Failed to cache registry record for {}: {}", appid, err);
    }
    invalidate_record(appid);
    let changed = [appid.to_string()];
    notify_changed(&changed);
    Ok(info)
}

/// This app's grant when the cache holds a current answer for its channel.
///
/// `Some(None)` is a fresh "no constraint"; `None` means nothing usable is
/// cached and the caller has to ask. A grant gates as hard as a status does, so
/// it ages on the status TTL rather than the listing TTL.
pub(crate) fn cached_grant(appid: &str, channel: LxAppChannel) -> Option<Option<LxAppPermissions>> {
    grant_of(&record(appid)?, channel, Some(STATUS_TTL))
}

/// The last grant we were given for this channel, however old.
pub(crate) fn standing_grant(appid: &str, channel: LxAppChannel) -> Option<LxAppPermissions> {
    grant_of(&record(appid)?, channel, None).flatten()
}

/// The grant this record holds for one channel, if it holds one at all.
///
/// Channels never borrow each other's answers, and each ages on its own stamp:
/// a listing refresh on the host channel must not make a draft's
/// grant look current, nor erase it.
fn grant_of(
    record: &RegistryRecord,
    channel: LxAppChannel,
    ttl: Option<Duration>,
) -> Option<Option<LxAppPermissions>> {
    let grant = record.grants.get(channel.as_str())?;
    if ttl.is_some_and(|ttl| stamp_is_expired(grant.fetched_at, ttl)) {
        return None;
    }
    Some(grant.permissions.as_ref().map(load_permissions))
}

/// Ask the registry for this app's grant. `None` is "unconstrained".
///
/// A registry that answers nothing, or cannot be reached, does not restrict
/// anything: only an explicit grant does. A transport failure keeps the
/// standing grant so a network blip cannot widen an app that was constrained.
pub(crate) async fn resolve_grant(appid: &str, channel: LxAppChannel) -> Option<LxAppPermissions> {
    match fetch_records(appid, channel).await {
        Ok(info) => info.and_then(|info| info.permissions),
        Err(err) => {
            crate::warn!("Registry grant lookup failed for {}: {}", appid, err).with_appid(appid);
            standing_grant(appid, channel)
        }
    }
}

fn store_permissions(permissions: &LxAppPermissions) -> StoredPermissions {
    StoredPermissions {
        domains: permissions
            .network
            .as_ref()
            .map(|network| network.trusted_domains.clone()),
        privileges: permissions
            .privileges
            .as_ref()
            .map(|privileges| privileges.granted.clone()),
    }
}

fn load_permissions(stored: &StoredPermissions) -> LxAppPermissions {
    let mut permissions = LxAppPermissions::all();
    if let Some(domains) = stored.domains.clone() {
        permissions = permissions.with_network(domains);
    }
    if let Some(privileges) = stored.privileges.clone() {
        permissions = permissions.with_privileges(privileges);
    }
    permissions
}

/// Grants to keep on the record [`fetch_records`] is about to store: every
/// other channel's answer, plus this channel's fresh one.
fn carry_grants(
    info: Option<&LxAppRegistryInfo>,
    previous: Option<&RegistryRecord>,
    channel: LxAppChannel,
) -> BTreeMap<String, StoredGrant> {
    let mut grants = previous
        .map(|previous| previous.grants.clone())
        .unwrap_or_default();
    grants.insert(
        channel.as_str().to_string(),
        StoredGrant {
            permissions: info
                .and_then(|info| info.permissions.as_ref())
                .map(store_permissions),
            fetched_at: now_secs(),
        },
    );
    grants
}

/// File name to keep on the record [`fetch_records`] is about to store.
///
/// The URL is the cache key. Carrying a file over under a *new* URL would
/// make [`resolve_icon_file`] treat that file as already matching and skip
/// the download. A missing or empty URL is a withdrawal. `info` of `None`
/// is a 404: keep last artwork.
fn carry_icon_file(
    info: Option<&LxAppRegistryInfo>,
    previous: Option<&RegistryRecord>,
) -> Option<String> {
    match info {
        Some(info) => {
            let new_url = info.icon_url.as_deref().filter(|url| !url.is_empty());
            let old_url = previous
                .and_then(|previous| previous.icon_url.as_deref())
                .filter(|url| !url.is_empty());
            if new_url.is_some() && new_url == old_url {
                previous.and_then(|previous| previous.icon_file.clone())
            } else {
                None
            }
        }
        None => previous.and_then(|previous| previous.icon_file.clone()),
    }
}

/// Bring cached artwork in line with records already stored by [`fetch_records`].
///
/// A record does not name its app: the request did, and echoing it back would
/// be a second answer to a question the caller already knows.
async fn fetch_icons(appid: &str, info: &LxAppRegistryInfo) {
    sweep_staging_files();
    let cached = record(appid);
    let Some(icon_file) = resolve_icon_file(appid, info, cached.as_ref()).await else {
        return;
    };
    let Some(mut record) = cached else {
        return;
    };
    if record.icon_file.as_deref() == Some(icon_file.as_str()) {
        return;
    }
    record.icon_file = Some(icon_file);
    if let Err(err) = metadata::registry_upsert(&record) {
        crate::warn!("Failed to cache registry icon for {}: {}", appid, err);
        return;
    }
    invalidate_record(appid);
    notify_changed(&[appid.to_string()]);
}

/// Returns the cached file name for this info's icon, downloading only when the
/// URL it came from has changed.
///
/// The URL is the cache key, so a registry that edits artwork behind a stable
/// URL is never picked up — the contract requires the URL to change with the
/// image. The file is still named by the bytes' own hash, so the same artwork
/// reached through two URLs, or by two lxapps, is one file on disk.
async fn resolve_icon_file(
    appid: &str,
    info: &LxAppRegistryInfo,
    cached: Option<&RegistryRecord>,
) -> Option<String> {
    let url = info.icon_url.as_deref().filter(|url| !url.is_empty())?;
    let dir = ensure_icons_dir()?;
    let extension = icon_extension(url);

    if let Some(cached) = cached
        && cached.icon_url.as_deref() == Some(url)
        && let Some(file) = cached.icon_file.as_deref()
        && dir.join(file).exists()
    {
        return Some(file.to_string());
    }

    let staging = dir.join(format!(
        "download-{}{}",
        uuid::Uuid::new_v4(),
        STAGING_SUFFIX
    ));
    let options = service_executor::DownloadOptions::new(url.to_string(), staging.clone())
        .with_connect_timeout(Duration::from_secs(10))
        .with_request_timeout(ICON_REQUEST_TIMEOUT);
    let receiver = service_executor::spawn_download(options, None)
        .map_err(|err| crate::warn!("Failed to start icon download: {}", err))
        .ok()?;
    match receiver.await {
        Ok(Ok(())) => {}
        Ok(Err(err)) => {
            crate::warn!("Icon download failed for {}: {}", appid, err);
            let _ = fs::remove_file(&staging);
            return None;
        }
        Err(_) => {
            let _ = fs::remove_file(&staging);
            return None;
        }
    }

    let digest = archive::sha256_hex(&staging).ok()?;
    let file = format!("{}.{}", digest, extension);
    let destination = dir.join(&file);
    // Content-addressed, so a destination that already exists holds exactly
    // these bytes: a concurrent refresh for another app resolving to the
    // same artwork won the race. POSIX rename would overwrite silently; on
    // Windows it errors, and treating that as failure would drop the icon.
    if destination.exists() {
        let _ = fs::remove_file(&staging);
        return Some(file);
    }
    if let Err(err) = fs::rename(&staging, &destination) {
        let _ = fs::remove_file(&staging);
        if !destination.exists() {
            crate::warn!("Failed to store cached icon for {}: {}", appid, err);
            return None;
        }
    }
    Some(file)
}

/// Delete staging files old enough that no live download owns them. Downloads
/// cancelled by the pre-open timeout are dropped without running their cleanup,
/// so nothing else would ever remove these.
fn sweep_staging_files() {
    let Some(dir) = icons_dir() else {
        return;
    };
    let Ok(entries) = fs::read_dir(&dir) else {
        return;
    };
    for entry in entries.flatten() {
        if !entry
            .file_name()
            .to_string_lossy()
            .ends_with(STAGING_SUFFIX)
        {
            continue;
        }
        let stale = entry
            .metadata()
            .and_then(|metadata| metadata.modified())
            .map(|modified| {
                modified
                    .elapsed()
                    .is_ok_and(|elapsed| elapsed > STAGING_MAX_AGE)
            })
            .unwrap_or(false);
        if stale {
            let _ = fs::remove_file(entry.path());
        }
    }
}

/// Drop an app's registry cache on uninstall, and the artwork no remaining app
/// references. Icons are content-addressed and therefore shared, so deletion is
/// by survivor set rather than by the removed app's file list.
pub(crate) fn clear(appid: &str) {
    // Records go first and unconditionally: an unavailable icon directory must
    // not leave an uninstalled app's name and status answering lookups.
    invalidate_record(appid);
    let orphan_candidates = match metadata::registry_remove_all(appid) {
        Ok(files) => files,
        Err(err) => {
            crate::warn!("Failed to clear registry cache for {}: {}", appid, err);
            return;
        }
    };
    // A lookup between the first invalidation and the removal re-cached the old record.
    invalidate_record(appid);
    if orphan_candidates.is_empty() {
        return;
    }
    let Some(dir) = icons_dir() else {
        return;
    };
    sweep_orphan_icons(&orphan_candidates, &dir);
}

fn sweep_orphan_icons(candidates: &[String], icons_dir: &Path) {
    let Ok(still_referenced) = metadata::registry_referenced_icon_files() else {
        return;
    };
    for file in candidates {
        if !still_referenced.contains(file) {
            let _ = fs::remove_file(icons_dir.join(file));
        }
    }
}

/// The name to show for an lxapp anywhere it is listed: the registry's answer,
/// else the name the installed package declares.
///
/// One entry point on purpose — a sidebar row and the window title reading
/// different sources is how the same app ends up with two names on screen.
/// Looking up the name is also what schedules a background `registryinfo`
/// refresh, so a server-side rename does not wait on an unrelated relayout.
pub fn display_name(appid: &str) -> Option<String> {
    ensure_fresh(&[appid]);
    name(appid)
        .or_else(|| runtime_registry::try_get(appid).map(|app| app.get_lxapp_info().app_name))
        .filter(|name| !name.trim().is_empty())
}

/// The icon to show for an lxapp: the cached registry artwork, or nothing.
///
/// The registry is the only source — a package declares no icon. Before the
/// first fetch lands, and for a local project the registry has never heard of,
/// callers get `None` and draw their own default mark.
pub fn display_icon_path(appid: &str) -> Option<String> {
    ensure_fresh(&[appid]);
    icon_path(appid).filter(|path| !path.trim().is_empty())
}

/// Registry state for an lxapp, for callers deciding whether to still offer it.
pub fn display_status(appid: &str) -> LxAppStatus {
    status(appid)
}

/// Ask for a background refresh of these apps' registry records. Call it where
/// a list of apps is built; it returns immediately and never fails the caller.
pub fn refresh_registry(appids: &[String]) {
    ensure_fresh(appids);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn icon_extension_keeps_known_image_types_and_defaults_to_png() {
        assert_eq!(icon_extension("https://cdn.example.com/a/logo.svg"), "svg");
        assert_eq!(icon_extension("https://cdn.example.com/a/logo.JPEG"), "jpg");
        assert_eq!(
            icon_extension("https://cdn.example.com/a/logo.webp?v=2"),
            "webp"
        );
        // No extension, and an unknown one, both land on the safe default.
        assert_eq!(icon_extension("https://cdn.example.com/icon/42"), "png");
        assert_eq!(icon_extension("https://cdn.example.com/a/logo.bin"), "png");
    }

    fn constrained(domain: &str) -> LxAppRegistryInfo {
        LxAppRegistryInfo {
            permissions: Some(LxAppPermissions::network([domain])),
            ..LxAppRegistryInfo::default()
        }
    }

    #[test]
    fn a_grant_belongs_to_the_channel_it_was_answered_for() {
        let mut record = record_for("demo", None);
        record.grants = carry_grants(
            Some(&constrained("api.example.com")),
            None,
            LxAppChannel::Release,
        );

        let release = grant_of(&record, LxAppChannel::Release, Some(STATUS_TTL))
            .expect("a current answer")
            .expect("an explicit grant");
        assert_eq!(
            release.network.map(|network| network.trusted_domains),
            Some(vec!["api.example.com".to_string()])
        );
        // The half the server said nothing about is not stored as a denial.
        assert!(release.privileges.is_none());

        // Another channel is not this grant, at any age.
        assert!(grant_of(&record, LxAppChannel::Draft, Some(STATUS_TTL)).is_none());
        assert!(grant_of(&record, LxAppChannel::Draft, None).is_none());

        // A grant that aged out is no longer a current answer, but it is still
        // the standing one — an unreachable registry must not widen the app.
        record
            .grants
            .get_mut("release")
            .expect("the release grant")
            .fetched_at -= STATUS_TTL.as_secs() as i64 + 1;
        assert!(grant_of(&record, LxAppChannel::Release, Some(STATUS_TTL)).is_none());
        assert!(
            grant_of(&record, LxAppChannel::Release, None)
                .unwrap()
                .is_some()
        );
    }

    #[test]
    fn a_refresh_on_one_channel_leaves_the_others_alone() {
        // The sidebar refreshes on the host channel while a draft of
        // the same app id holds its own grant. Neither may erase the other.
        let draft = carry_grants(
            Some(&constrained("dev.example.com")),
            None,
            LxAppChannel::Draft,
        );
        let mut record = record_for("demo", None);
        record.grants = draft;
        record.grants = carry_grants(
            Some(&constrained("api.example.com")),
            Some(&record),
            LxAppChannel::Release,
        );

        for (channel, host) in [
            (LxAppChannel::Draft, "dev.example.com"),
            (LxAppChannel::Release, "api.example.com"),
        ] {
            let grant = grant_of(&record, channel, Some(STATUS_TTL))
                .expect("a current answer")
                .expect("an explicit grant");
            assert_eq!(
                grant.network.map(|network| network.trusted_domains),
                Some(vec![host.to_string()]),
                "{channel}"
            );
        }
    }

    #[test]
    fn a_channel_with_no_grant_is_a_current_answer_of_no_constraint() {
        let mut record = record_for("demo", None);
        record.grants = carry_grants(None, None, LxAppChannel::Release);
        assert!(
            grant_of(&record, LxAppChannel::Release, Some(STATUS_TTL))
                .expect("a current answer")
                .is_none()
        );

        // Never asked on this channel: no answer at all, so the caller fetches.
        assert!(grant_of(&record, LxAppChannel::Draft, Some(STATUS_TTL)).is_none());
    }

    fn record_for(appid: &str, icon_file: Option<&str>) -> RegistryRecord {
        RegistryRecord {
            appid: appid.to_string(),
            name: Some(format!("{appid}-name")),
            description: None,
            icon_url: None,
            icon_file: icon_file.map(str::to_string),
            status: LxAppStatus::Published.as_str().to_string(),
            grants: BTreeMap::new(),
            fetched_at: now_secs(),
        }
    }

    #[test]
    fn a_nameless_record_is_refreshed_instead_of_waiting_out_the_listing_ttl() {
        let mut record = record_for("demo", None);
        assert!(!listing_needs_refresh(&record));

        record.name = None;
        assert!(!is_expired(&record, LISTING_TTL));
        assert!(listing_needs_refresh(&record));

        record.name = Some("   ".to_string());
        assert!(listing_needs_refresh(&record));
    }

    #[test]
    fn expiry_uses_the_ttl_it_is_given() {
        let mut record = record_for("demo", None);
        assert!(!is_expired(&record, STATUS_TTL));

        // Aged past the status window but still inside the listing window: the
        // icon stays usable while the status must be re-confirmed.
        record.fetched_at = now_secs() - (STATUS_TTL.as_secs() as i64) - 1;
        assert!(is_expired(&record, STATUS_TTL));
        assert!(!is_expired(&record, LISTING_TTL));
    }

    #[test]
    fn a_record_stamped_in_the_future_is_expired_not_eternally_fresh() {
        let mut record = record_for("demo", None);
        // A clock that ran ahead before NTP corrected it. Saturating the
        // subtraction would make this record outlive every later suspension.
        record.fetched_at = now_secs() + 60 * 60 * 24 * 365;
        assert!(is_expired(&record, STATUS_TTL));
        assert!(is_expired(&record, LISTING_TTL));
    }

    #[test]
    fn only_suspended_blocks_opening() {
        assert!(LxAppStatus::Suspended.blocks_open());
        // Delisted apps are no longer offered, but an installed copy keeps working.
        assert!(!LxAppStatus::Delisted.blocks_open());
        assert!(!LxAppStatus::Published.blocks_open());
        assert!(!LxAppStatus::Unknown.blocks_open());
    }

    #[test]
    fn unknown_server_states_degrade_instead_of_blocking() {
        let status = LxAppStatus::from_str_lossy("quarantined-pending-review");
        assert_eq!(status, LxAppStatus::Unknown);
        assert!(!status.blocks_open());
    }

    /// Serialized because the metadata database is a process-wide singleton.
    fn with_store<T>(body: impl FnOnce(&Path) -> T) -> T {
        static STORE: OnceLock<Mutex<PathBuf>> = OnceLock::new();
        let dir = STORE.get_or_init(|| {
            let root = std::env::temp_dir().join(format!("lx-registry-{}", uuid::Uuid::new_v4()));
            fs::create_dir_all(&root).expect("create test cache root");
            metadata::init(root.join("metadata.redb")).expect("init metadata database");
            Mutex::new(root)
        });
        let root = dir.lock().unwrap_or_else(|err| err.into_inner());
        body(&root)
    }

    #[test]
    fn uninstall_keeps_artwork_another_app_still_references() {
        with_store(|icons| {
            let shared = "shared-artwork.png";
            let solo = "solo-artwork.png";
            fs::write(icons.join(shared), b"shared").unwrap();
            fs::write(icons.join(solo), b"solo").unwrap();

            // Two apps resolved to the same artwork; content addressing means
            // one file, so uninstalling either must not orphan the other's icon.
            metadata::registry_upsert(&record_for("com.example.keeper", Some(shared))).unwrap();
            metadata::registry_upsert(&record_for("com.example.leaver", Some(shared))).unwrap();
            metadata::registry_upsert(&record_for("com.example.other", Some(solo))).unwrap();

            let orphans = metadata::registry_remove_all("com.example.leaver").unwrap();
            sweep_orphan_icons(&orphans, icons);

            assert!(
                metadata::registry_get("com.example.leaver")
                    .unwrap()
                    .is_none()
            );
            assert!(
                icons.join(shared).exists(),
                "still referenced by the keeper"
            );
            assert!(icons.join(solo).exists(), "still referenced by the other");

            let orphans = metadata::registry_remove_all("com.example.other").unwrap();
            sweep_orphan_icons(&orphans, icons);
            assert!(!icons.join(solo).exists(), "last reference went away");

            let orphans = metadata::registry_remove_all("com.example.keeper").unwrap();
            sweep_orphan_icons(&orphans, icons);
            assert!(!icons.join(shared).exists());
        });
    }

    #[test]
    fn artwork_the_os_purged_counts_as_stale_however_fresh_the_record_is() {
        let dir = std::env::temp_dir().join(format!("lx-icon-gone-{}", uuid::Uuid::new_v4()));
        fs::create_dir_all(&dir).unwrap();
        let present = "kept.png";
        fs::write(dir.join(present), b"art").unwrap();

        let mut record = record_for("demo", Some(present));
        assert!(!cached_icon_is_gone(&record, &dir));

        // Records live in the data dir and icons in the cache dir; a cache
        // purge leaves this record fresh but pointing at nothing.
        record.icon_file = Some("purged.png".to_string());
        assert!(!is_expired(&record, LISTING_TTL));
        assert!(cached_icon_is_gone(&record, &dir));

        // A record that never named artwork is not stale for this reason.
        record.icon_file = None;
        assert!(!cached_icon_is_gone(&record, &dir));
        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn a_blocked_open_reports_which_state_blocked_it() {
        // The two blocking states need different messages to a user, so the
        // status has to survive as data rather than only inside a sentence.
        for status in [LxAppStatus::Suspended, LxAppStatus::Maintain] {
            let error = unavailable_error("com.example.app", status);
            assert_eq!(registry_unavailable_status(&error), Some(status));
            let LxAppError::RongJSHost { data, message, .. } = error else {
                panic!("a blocked open must carry structured data, got a bare error");
            };
            let data = data.expect("blocked open carries data");
            assert_eq!(data["code"], status.as_str());
            assert_eq!(data["appId"], "com.example.app");
            // The message stays useful for a host that only shows raw text.
            assert!(message.contains("com.example.app"));
        }
    }

    #[test]
    fn artwork_is_kept_only_when_its_url_is_unchanged() {
        let previous = RegistryRecord {
            appid: "demo".to_string(),
            name: None,
            description: None,
            icon_url: Some("https://cdn.example.com/a.png".to_string()),
            icon_file: Some("old.png".to_string()),
            status: LxAppStatus::Published.as_str().to_string(),
            grants: BTreeMap::new(),
            fetched_at: now_secs(),
        };
        let same = LxAppRegistryInfo {
            icon_url: Some("https://cdn.example.com/a.png".to_string()),
            ..Default::default()
        };
        assert_eq!(
            carry_icon_file(Some(&same), Some(&previous)).as_deref(),
            Some("old.png")
        );

        let changed = LxAppRegistryInfo {
            icon_url: Some("https://cdn.example.com/b.png".to_string()),
            ..same.clone()
        };
        assert_eq!(carry_icon_file(Some(&changed), Some(&previous)), None);

        let withdrawn = LxAppRegistryInfo {
            icon_url: None,
            ..same.clone()
        };
        assert_eq!(carry_icon_file(Some(&withdrawn), Some(&previous)), None);

        let empty = LxAppRegistryInfo {
            icon_url: Some(String::new()),
            ..same
        };
        assert_eq!(carry_icon_file(Some(&empty), Some(&previous)), None);

        // A 404 keeps last artwork so an unpublished app does not blank its icon.
        assert_eq!(
            carry_icon_file(None, Some(&previous)).as_deref(),
            Some("old.png")
        );
    }

    #[test]
    fn a_recent_attempt_suppresses_the_next_refresh() {
        let appid = "com.example.backoff";
        assert!(!attempted_recently(appid));
        // The sidebar asks on every layout pass; a fast provider failure would
        // otherwise let each pass start another request.
        mark_attempted(appid);
        assert!(attempted_recently(appid));
        assert!(!attempted_recently("com.example.other"));
    }
}