Merge #473 app.db pagination fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
This commit is contained in:
phantomptrandClaude Opus 5.5 committed 2026-10-17 14:53:37 -07:00
commit 7d510c0f06
16 files changed
+1130 -99

No files matched your search

+3 -3
View File
@@ -174,9 +174,9 @@ static int handle_appinfo_set(void *st, int fd, uint64_t t, const char *b, uint6
if (strstr(b, "RUNNING")) return ok_body("{\"ok\":false,\"err\":\"title is running\"}");
return ok_body("{\"ok\":true,\"err\":null}");
}
static int handle_appdb_query(void *st, int fd, uint64_t t) {
(void)st; (void)fd; (void)t;
note(AVA1_METHOD_APP_DB_QUERY, NULL);
static int handle_appdb_query(void *st, int fd, uint64_t t, const char *b, uint64_t l) {
(void)st; (void)fd; (void)t; (void)l;
note(AVA1_METHOD_APP_DB_QUERY, b);
return ok_body("{\"apps\":[{\"title_id\":\"CUSA00001\",\"app_id\":57368,\"name\":\"Stub\"}],\"source\":\"sql\"}");
}
static int handle_focus_probe(void *st, int fd, uint64_t t) {
@@ -247,12 +247,103 @@ pub struct AppDbList {
/// a caller that sees a blank name knows whether to believe it.
#[serde(default)]
pub source: Option<String>,
/// One page's window (#473): the first row it holds, the row the next page starts at, and
/// whether one follows. Absent from an older helper, which answers one unpaged reply, and
/// from the list [`appdb_query`] returns, which is every page joined.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offset: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub more: Option<bool>,
/// The table's row count when the page was read (SQL reader only). A change between pages
/// means rows moved under the walk, so one may have been skipped: the list is truncated.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total: Option<u64>,
/// True when app.db holds rows this list does not: the helper could not deliver them, a
/// page failed (`err` says why), the page bound was reached, or an older helper answered
/// a full 256 rows (its old cap, which it could not report). A truncated list is never
/// proof that a title is absent.
#[serde(default)]
pub truncated: bool,
}
/// Query app.db for the title_id ↔ app_id ↔ name mapping.
/// The most app.db pages [`appdb_query`] reads. A page holds up to 512 rows, so this is
/// far past any real console; it bounds the loop against a helper that never says it is done.
pub const APPDB_MAX_PAGES: usize = 32;
/// The row count an older, unpaged helper stopped at without saying so.
const APPDB_LEGACY_CAP: usize = 256;
/// Query app.db for the title_id ↔ app_id ↔ name mapping: every page of it (#473).
///
/// The helper answers a window (`{"offset":N}` in; `next`, `more` and `truncated` out); this
/// follows it until `more` is false, at most [`APPDB_MAX_PAGES`] calls. An older helper ignores
/// the window and answers once, which is then the whole answer. Whatever stops the walk early
/// sets `truncated` on the result, so callers can tell a partial list from a complete one:
/// a failure after the first page keeps the rows read so far, with the reason in `err`.
pub fn appdb_query(addr: &str) -> Result<AppDbList> {
let resp = mgmt::call(addr, m::APP_DB_QUERY, &[])?;
Ok(serde_json::from_slice(&resp)?)
let mut list: Option<AppDbList> = None;
let mut offset: u64 = 0;
for _ in 0..APPDB_MAX_PAGES {
let body = serde_json::json!({ "offset": offset }).to_string();
let page = mgmt::call(addr, m::APP_DB_QUERY, body.as_bytes())
.and_then(|resp| Ok(serde_json::from_slice::<AppDbList>(&resp)?));
let mut page = match (page, list.as_mut()) {
(Ok(p), _) => p,
// Page one failing is the call failing, as it always was.
(Err(e), None) => return Err(e),
(Err(e), Some(l)) => {
l.truncated = true;
l.err = Some(format!("app.db page at row {offset}: {e:#}"));
break;
}
};
let paged = page.more.is_some() || page.next.is_some();
let more = page.more.unwrap_or(false);
let next = page.next.unwrap_or(offset + page.apps.len() as u64);
let got = page.apps.len();
let total_moved = match (list.as_ref().and_then(|l| l.total), page.total) {
(Some(a), Some(b)) => a != b,
_ => false,
};
let page_truncated = page.truncated
|| total_moved
|| (!paged && got >= APPDB_LEGACY_CAP)
// A page that claims more but does not move forward would loop.
|| (more && next <= offset);
match list.as_mut() {
None => {
page.truncated = page_truncated;
list = Some(page);
}
Some(l) => {
l.apps.append(&mut page.apps);
l.truncated |= page_truncated;
if page.err.is_some() {
l.err = page.err.take();
l.truncated = true;
}
}
}
let l = list.as_mut().expect("set above");
if !paged || !more || next <= offset || l.err.is_some() {
l.offset = None;
l.next = None;
l.more = None;
l.total = None;
return Ok(list.expect("set above"));
}
offset = next;
}
// Here only when a later page failed or the page bound ran out with pages still to read.
let mut l = list.expect("APPDB_MAX_PAGES > 0");
l.truncated = true;
l.offset = None;
l.next = None;
l.more = None;
l.total = None;
Ok(l)
}
/// One (key, value) row of appinfo.db for a title.
@@ -320,14 +320,26 @@ fn probe_app_pkg_at(addr: &str, app_dir: &str) -> PkgProbe {
/// Whether app.db lists `title_id`. `Some(true/false)` only when the query
/// actually returned a row set (`err == None`); `None` when the database
/// could not be read at all, so an unreadable database never counts as
/// "the title is absent".
/// "the title is absent". Likewise a list the helper could only partly
/// deliver (`truncated`, #473): finding the title there is proof, missing
/// it is not.
fn appdb_has_title(addr: &str, title_id: &str) -> Option<bool> {
match crate::diagnostics::appdb_query(addr) {
Ok(list) if list.err.is_none() => Some(list.apps.iter().any(|a| a.title_id == title_id)),
Ok(list) if list.err.is_none() => appdb_list_has_title(&list, title_id),
_ => None,
}
}
fn appdb_list_has_title(list: &crate::diagnostics::AppDbList, title_id: &str) -> Option<bool> {
if list.apps.iter().any(|a| a.title_id == title_id) {
Some(true)
} else if list.truncated {
None
} else {
Some(false)
}
}
/// Check whether `title_id` (e.g. "PPSA01650") installed a launchable title
/// on the PS5.
///
@@ -498,3 +510,46 @@ mod tests {
assert_eq!(p, InstallPhase::Done);
}
}
#[cfg(test)]
mod appdb_list_has_title_tests {
use super::appdb_list_has_title;
use crate::diagnostics::{AppDbEntry, AppDbList};
fn list(ids: &[&str], truncated: bool) -> AppDbList {
AppDbList {
apps: ids
.iter()
.map(|t| AppDbEntry {
title_id: t.to_string(),
app_id: 1,
name: String::new(),
})
.collect(),
err: None,
source: None,
offset: None,
next: None,
more: None,
total: None,
truncated,
}
}
/// #473: a title missing from a list the helper could not finish is unknown, not absent.
#[test]
fn a_truncated_list_never_proves_a_title_absent() {
assert_eq!(
appdb_list_has_title(&list(&["CUSA00001"], true), "CUSA00001"),
Some(true)
);
assert_eq!(
appdb_list_has_title(&list(&["CUSA00001"], true), "PPSA00002"),
None
);
assert_eq!(
appdb_list_has_title(&list(&["CUSA00001"], false), "PPSA00002"),
Some(false)
);
}
}
@@ -0,0 +1,232 @@
//! #473: `appdb_query` pages through app.db instead of trusting one capped reply.
//!
//! The helper used to answer at most 256 rows (and fewer when its buffer filled) with nothing
//! saying the list was cut. It now answers a window (`offset`/`limit` in, `next`/`more`/`truncated`
//! out); the engine follows it to the end, bounded, and says when the list is not whole.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{anyhow, Result};
use ps5upload_core::diagnostics::{appdb_query, APPDB_MAX_PAGES};
use ps5upload_core::mgmt::{self, Method, MgmtTransport};
use serde_json::{json, Value};
type Script = Box<dyn Fn(&Value) -> Result<Option<Vec<u8>>> + Send + Sync>;
struct Fake {
seen: Mutex<Vec<Value>>,
reply: Script,
}
impl MgmtTransport for Fake {
fn call(
&self,
_addr: &str,
method: Method,
_label: &str,
body: &[u8],
_timeout: Duration,
) -> Result<Option<Vec<u8>>> {
assert_eq!(method.id, 56, "app.db_query");
let req: Value = if body.is_empty() {
json!({})
} else {
serde_json::from_slice(body).expect("the request is JSON")
};
self.seen.lock().unwrap().push(req.clone());
(self.reply)(&req)
}
}
fn fake(
f: impl Fn(&Value) -> Result<Option<Vec<u8>>> + Send + Sync + 'static,
) -> (Arc<Fake>, mgmt::ScopedTransport) {
let t = Arc::new(Fake {
seen: Mutex::default(),
reply: Box::new(f),
});
let g = mgmt::scoped_transport(t.clone());
(t, g)
}
fn title(i: usize) -> String {
format!("CUSA{i:05}")
}
/// A helper that serves `rows` titles (plus a late-sorting ZZZZ99999) `page` at a time.
fn paged_helper(rows: usize, page: usize) -> impl Fn(&Value) -> Result<Option<Vec<u8>>> {
move |req| {
let mut all: Vec<String> = (0..rows).map(title).collect();
all.push("ZZZZ99999".into());
let off = req["offset"].as_u64().unwrap_or(0) as usize;
let end = (off + page).min(all.len());
let apps: Vec<Value> = all[off.min(all.len())..end]
.iter()
.map(|t| json!({"title_id": t, "app_id": 1, "name": format!("Game {t}")}))
.collect();
Ok(Some(
serde_json::to_vec(&json!({
"apps": apps,
"source": "sqlite",
"offset": off,
"next": end,
"more": end < all.len(),
"truncated": false,
"total": all.len(),
}))
.unwrap(),
))
}
}
const A: &str = "10.0.0.5";
#[test]
fn pages_until_the_helper_says_there_is_no_more() {
for rows in [255usize, 256, 257, 1000, 1500] {
let (t, _g) = fake(paged_helper(rows, 400));
let d = appdb_query(A).unwrap();
assert_eq!(d.apps.len(), rows + 1, "{rows} rows");
assert!(!d.truncated, "{rows} rows");
assert!(d.err.is_none());
assert_eq!(d.apps.last().unwrap().title_id, "ZZZZ99999");
assert_eq!(d.source.as_deref(), Some("sqlite"));
let seen = t.seen.lock().unwrap();
assert_eq!(seen.len(), (rows + 1).div_ceil(400), "{rows} rows");
for (i, req) in seen.iter().enumerate() {
assert_eq!(req["offset"], json!(i * 400), "page {i}");
}
}
}
#[test]
fn an_older_helper_answers_once_and_a_full_256_is_not_called_whole() {
// No next/more: the helper predates paging, so one reply is all there is.
let (t, _g) = fake(|_| {
let apps: Vec<Value> = (0..256)
.map(|i| json!({"title_id": title(i), "app_id": 1, "name": "n"}))
.collect();
Ok(Some(
serde_json::to_vec(&json!({"apps": apps, "source": "sqlite"})).unwrap(),
))
});
let d = appdb_query(A).unwrap();
assert_eq!(d.apps.len(), 256);
assert!(d.truncated, "256 rows from an older helper may be its cap");
assert_eq!(t.seen.lock().unwrap().len(), 1);
drop(_g);
let (_t, _g) = fake(|_| {
Ok(Some(
br#"{"apps":[{"title_id":"CUSA00001","app_id":1,"name":"n"}],"source":"scan"}"#
.to_vec(),
))
});
let d = appdb_query(A).unwrap();
assert_eq!(d.apps.len(), 1);
assert!(!d.truncated);
}
#[test]
fn a_truncated_page_marks_the_whole_list_truncated() {
let (_t, _g) = fake(|req| {
let off = req["offset"].as_u64().unwrap_or(0);
Ok(Some(
serde_json::to_vec(&if off == 0 {
json!({"apps":[{"title_id":"CUSA00001","app_id":1,"name":"a"}],
"offset":0,"next":1,"more":true,"truncated":false})
} else {
json!({"apps":[],"offset":1,"next":1,"more":false,"truncated":true})
})
.unwrap(),
))
});
let d = appdb_query(A).unwrap();
assert_eq!(d.apps.len(), 1);
assert!(d.truncated);
}
#[test]
fn a_page_that_makes_no_progress_stops_the_loop() {
let (t, _g) = fake(|_| {
Ok(Some(
br#"{"apps":[],"offset":0,"next":0,"more":true,"truncated":false}"#.to_vec(),
))
});
let d = appdb_query(A).unwrap();
assert!(d.truncated);
assert_eq!(t.seen.lock().unwrap().len(), 1);
}
#[test]
fn the_page_count_is_bounded() {
// A helper that always has more: the engine gives up after APPDB_MAX_PAGES calls.
let (t, _g) = fake(|req| {
let off = req["offset"].as_u64().unwrap_or(0);
Ok(Some(
serde_json::to_vec(&json!({
"apps":[{"title_id": title(off as usize), "app_id": 1, "name": "n"}],
"offset": off, "next": off + 1, "more": true, "truncated": false
}))
.unwrap(),
))
});
let d = appdb_query(A).unwrap();
assert!(d.truncated);
assert_eq!(d.apps.len(), APPDB_MAX_PAGES);
assert_eq!(t.seen.lock().unwrap().len(), APPDB_MAX_PAGES);
}
#[test]
fn errors_keep_their_meaning() {
// The helper's err envelope on page one is the answer, as before.
let (_t, _g) = fake(|_| Ok(Some(br#"{"err":"cannot read app.db","apps":[]}"#.to_vec())));
let d = appdb_query(A).unwrap();
assert_eq!(d.err.as_deref(), Some("cannot read app.db"));
assert!(d.apps.is_empty());
drop(_g);
// A transport failure on page one is still an error.
let (_t, _g) = fake(|_| Err(anyhow!("connection reset")));
assert!(appdb_query(A).is_err());
drop(_g);
// A failure after page one keeps the rows read so far, flagged incomplete with the reason,
// so a caller never mistakes the partial list for the whole one.
let (_t, _g) = fake(|req| {
if req["offset"].as_u64().unwrap_or(0) == 0 {
Ok(Some(
br#"{"apps":[{"title_id":"CUSA00001","app_id":1,"name":"a"}],"offset":0,"next":1,"more":true,"truncated":false}"#
.to_vec(),
))
} else {
Err(anyhow!("connection reset"))
}
});
let d = appdb_query(A).unwrap();
assert_eq!(d.apps.len(), 1);
assert!(d.truncated);
assert!(d.err.as_deref().unwrap_or("").contains("connection reset"));
}
#[test]
fn a_row_count_that_changes_between_pages_marks_the_list_truncated() {
// A row deleted between pages shifts the window: the first row of page two was skipped.
let (_t, _g) = fake(|req| {
let off = req["offset"].as_u64().unwrap_or(0);
Ok(Some(
serde_json::to_vec(&if off == 0 {
json!({"apps":[{"title_id":"CUSA00001","app_id":1,"name":"a"}],
"offset":0,"next":1,"more":true,"truncated":false,"total":3})
} else {
json!({"apps":[{"title_id":"CUSA00003","app_id":1,"name":"c"}],
"offset":1,"next":2,"more":false,"truncated":false,"total":2})
})
.unwrap(),
))
});
let d = appdb_query(A).unwrap();
assert_eq!(d.apps.len(), 2);
assert!(d.truncated);
}
+218 -44
View File
@@ -4934,6 +4934,40 @@ struct InstalledAppsResponse {
/// UI then can't reliably tag the "registered" group, so it shows
/// everything under "installed" with a soft note rather than erroring.
registered_unavailable: bool,
/// True when the list may be missing titles: a directory listing hit its page size,
/// or app.db could not be read in full (a failed query, or a helper that could not
/// deliver every row, #473). The titles shown are real; there may be more.
incomplete: bool,
}
/// app.db, read at most once per request: a paged read is several calls (#473), and the
/// installed list asks it for names title by title.
struct AppDbOnce<'a> {
addr: &'a str,
got: Option<Result<ps5upload_core::diagnostics::AppDbList, String>>,
}
impl<'a> AppDbOnce<'a> {
fn new(addr: &'a str) -> Self {
Self { addr, got: None }
}
fn get(&mut self) -> &Result<ps5upload_core::diagnostics::AppDbList, String> {
let addr = self.addr;
self.got
.get_or_insert_with(|| appdb_query(addr).map_err(|e| format!("{e:#}")))
}
/// app.db's display name for `title_id`, when it has one that is not the id itself.
fn name_of(&mut self, title_id: &str) -> Option<String> {
let list = self.get().as_ref().ok()?;
list.apps
.iter()
.filter(|a| a.title_id == title_id)
.map(|a| a.name.trim())
.find(|n| !n.is_empty() && *n != title_id)
.map(str::to_string)
}
}
/// PS5 title-ids are 4 uppercase letters + 5 digits (PPSA01234, NPXS39041,
@@ -4963,7 +4997,7 @@ pub(crate) fn is_console_webui_tile(title_id: &str) -> bool {
/// `app.db` (raw file scan when sqlite is unavailable). Returns None on
/// any failure (no metadata file — common for system apps — bad JSON,
/// bad SFO, path denied); the caller falls back to the bare title_id.
fn appmeta_title_name(addr: &str, title_id: &str) -> Option<String> {
fn appmeta_title_name(addr: &str, title_id: &str, appdb: &mut AppDbOnce<'_>) -> Option<String> {
// /user/appmeta/<id> first; then the app's own sce_sys, which is all a
// title installed by a homebrew installer has on FW 13.60.
for dir in [
@@ -4994,17 +5028,7 @@ fn appmeta_title_name(addr: &str, title_id: &str) -> Option<String> {
// This is the only way to resolve PS4 BC titles (CUSA IDs) whose
// metadata lives solely in app.db — their appmeta dirs have no
// param.sfo or param.json.
if let Ok(appdb) = appdb_query(addr) {
for app in &appdb.apps {
if app.title_id == title_id {
let name = app.name.trim();
if !name.is_empty() && name != title_id {
return Some(name.to_string());
}
}
}
}
None
appdb.name_of(title_id)
}
/// Titles app.db knows are installed that the /user/appmeta scan missed,
@@ -5038,6 +5062,48 @@ fn appdb_installed_additions(
out
}
/// Candidates for titles whose app folder exists but that neither the /user/appmeta scan
/// nor app.db produced (#473), taken from the helper's registered list.
///
/// Only when app.db could not be read in full (`appdb_whole` false): with a complete app.db, a
/// folder it does not list is not an installed title (a failed install, a partial uninstall),
/// and the helper's list holds every /user/app folder, not only titles we registered. System
/// ids (NPXS) are left out, as in [`appdb_installed_additions`]. The caller still checks each
/// candidate for evidence it is a title (a mount.lnk source or a param file). Sorted, so the
/// list does not reshuffle between refreshes.
fn registered_only_additions<'a>(
registered: impl IntoIterator<Item = &'a str>,
app_dirs: &std::collections::HashSet<String>,
seen: &std::collections::HashSet<String>,
appdb_whole: bool,
) -> Vec<String> {
if appdb_whole {
return Vec::new();
}
let mut out: Vec<String> = registered
.into_iter()
.map(str::trim)
.filter(|tid| {
looks_like_title_id(tid)
&& !tid.starts_with("NPXS")
&& app_dirs.contains(*tid)
&& !seen.contains(*tid)
})
.map(str::to_string)
.collect();
out.sort();
out.dedup();
out
}
/// True when `/user/app/<id>/sce_sys` holds a param.json or param.sfo: the folder is a title,
/// not the leftovers of a failed install.
fn app_folder_has_param(addr: &str, title_id: &str) -> bool {
["param.json", "param.sfo"]
.iter()
.any(|f| fs_read(addr, &format!("/user/app/{title_id}/sce_sys/{f}"), 0, 16).is_ok())
}
/// GET /api/ps5/apps/installed?addr=IP:MGMT_PORT
async fn ps5_apps_installed(
State(state): State<AppState>,
@@ -5064,6 +5130,7 @@ async fn ps5_apps_installed(
}
};
let mut appdb = AppDbOnce::new(&addr);
// Full installed set: every /user/appmeta/<title_id>/ dir.
let listing = list_dir(
&addr,
@@ -5088,7 +5155,7 @@ async fn ps5_apps_installed(
// the id (older registrations stored id-as-name), try
// param.json for a friendlier label.
let name = if reg.title_name.trim().is_empty() || reg.title_name == tid {
appmeta_title_name(&addr, &tid).unwrap_or_else(|| tid.clone())
appmeta_title_name(&addr, &tid, &mut appdb).unwrap_or_else(|| tid.clone())
} else {
reg.title_name.clone()
};
@@ -5101,7 +5168,8 @@ async fn ps5_apps_installed(
system,
});
} else {
let name = appmeta_title_name(&addr, &tid).unwrap_or_else(|| tid.clone());
let name =
appmeta_title_name(&addr, &tid, &mut appdb).unwrap_or_else(|| tid.clone());
titles.push(InstalledApp {
title_id: tid.clone(),
title_name: name,
@@ -5152,40 +5220,92 @@ async fn ps5_apps_installed(
complete = complete
&& crate::console_snapshot::app_folders_whole(internal_listing, app_dirs.len());
if !app_dirs.is_empty() {
match appdb_query(&addr) {
Ok(appdb) => {
let seen: std::collections::HashSet<String> =
titles.iter().map(|t| t.title_id.clone()).collect();
let rows: Vec<(String, String)> = appdb
.apps
.into_iter()
.map(|a| (a.title_id, a.name))
.collect();
for (tid, db_name) in appdb_installed_additions(&rows, &app_dirs, &seen) {
let name = match db_name {
Some(n) => n,
None => {
appmeta_title_name(&addr, &tid).unwrap_or_else(|| tid.clone())
}
};
let (origin, image_backed, source) = match reg_map.get(&tid) {
Some(reg) => ("registered", reg.image_backed, reg.src.clone()),
None => ("pkg", false, String::new()),
};
titles.push(InstalledApp {
title_id: tid,
title_name: name,
origin: origin.to_string(),
image_backed,
source,
system: false,
});
// Whether app.db answered in full: only then is a folder it does not list
// known not to be an installed title.
let mut appdb_whole = true;
let rows: Option<Vec<(String, String)>> = match appdb.get() {
Ok(list) => {
// `err` with no rows: the helper could not read app.db at all.
if list.truncated || list.err.is_some() {
// Every row the helper delivered is used; the list just may not be whole.
crate::log_warn!(
"apps/installed: app.db read incomplete ({} rows{})",
list.apps.len(),
list.err
.as_deref()
.map(|e| format!(": {e}"))
.unwrap_or_default()
);
complete = false;
appdb_whole = false;
}
Some(
list.apps
.iter()
.map(|a| (a.title_id.clone(), a.name.clone()))
.collect(),
)
}
Err(e) => {
crate::log_warn!("apps/installed: app.db query failed: {e:#}");
crate::log_warn!("apps/installed: app.db query failed: {e}");
complete = false;
appdb_whole = false;
None
}
};
if let Some(rows) = rows {
let seen: std::collections::HashSet<String> =
titles.iter().map(|t| t.title_id.clone()).collect();
for (tid, db_name) in appdb_installed_additions(&rows, &app_dirs, &seen) {
let name = match db_name {
Some(n) => n,
None => appmeta_title_name(&addr, &tid, &mut appdb)
.unwrap_or_else(|| tid.clone()),
};
let (origin, image_backed, source) = match reg_map.get(&tid) {
Some(reg) => ("registered", reg.image_backed, reg.src.clone()),
None => ("pkg", false, String::new()),
};
titles.push(InstalledApp {
title_id: tid,
title_name: name,
origin: origin.to_string(),
image_backed,
source,
system: false,
});
}
}
// When app.db could not be read in full, a title it did not get to still
// has its folder and its registration (#473): list it if the folder holds a
// title (our mount.lnk source, or a param file), never a bare leftover folder.
let seen: std::collections::HashSet<String> =
titles.iter().map(|t| t.title_id.clone()).collect();
for tid in registered_only_additions(
reg_map.keys().map(String::as_str),
&app_dirs,
&seen,
appdb_whole,
) {
let Some(reg) = reg_map.get(&tid) else {
continue;
};
if reg.src.trim().is_empty() && !app_folder_has_param(&addr, &tid) {
continue;
}
let name = if reg.title_name.trim().is_empty() || reg.title_name == tid {
appmeta_title_name(&addr, &tid, &mut appdb).unwrap_or_else(|| tid.clone())
} else {
reg.title_name.clone()
};
titles.push(InstalledApp {
system: false,
title_id: tid,
title_name: name,
origin: "registered".to_string(),
image_backed: reg.image_backed,
source: reg.src.clone(),
});
}
}
// Kept for the game page. System apps are not games.
@@ -5225,6 +5345,7 @@ async fn ps5_apps_installed(
Ok(InstalledAppsResponse {
titles,
registered_unavailable,
incomplete: !complete,
})
})
.await
@@ -12567,7 +12688,7 @@ mod job_stage_tests {
#[cfg(test)]
mod appdb_installed_additions_tests {
use super::appdb_installed_additions;
use super::{appdb_installed_additions, registered_only_additions};
use std::collections::HashSet;
fn set(v: &[&str]) -> HashSet<String> {
@@ -12598,6 +12719,59 @@ mod appdb_installed_additions_tests {
);
}
/// #473: a title past the first 256 app.db rows is still added once the
/// rows arrive — the window, not the filter, was what hid it.
#[test]
fn a_late_sorting_title_past_row_256_is_kept() {
let mut rows: Vec<(String, String)> = (0..1000)
.map(|i| (format!("CUSA{i:05}"), format!("Game {i}")))
.collect();
rows.insert(0, ("AAAA00001".to_string(), "Early".to_string()));
rows.push(("ZZZZ99999".to_string(), "Late Title".to_string()));
let got = appdb_installed_additions(&rows, &set(&["ZZZZ99999"]), &HashSet::new());
assert_eq!(
got,
vec![("ZZZZ99999".to_string(), Some("Late Title".to_string()))]
);
}
/// #473: when app.db could not be read in full, a registered title with its
/// app folder but no appmeta and no app.db row is a candidate.
#[test]
fn an_incomplete_appdb_read_reconciles_registered_titles_with_a_folder() {
let got = registered_only_additions(
[
"PPSA00002",
"ZZZZ99999",
"CUSA00001",
"PPSA00003",
"NPXS40056",
"not-an-id",
],
&set(&[
"ZZZZ99999",
"CUSA00001",
"PPSA00002",
"NPXS40056",
"not-an-id",
]),
// already listed by the appmeta scan or app.db
&set(&["CUSA00001"]),
false,
);
// PPSA00003 has no folder; NPXS is a system id, never added this way.
assert_eq!(got, vec!["PPSA00002".to_string(), "ZZZZ99999".to_string()]);
}
/// A complete app.db read that does not list a folder means the folder is not an
/// installed title (a failed install, a partial uninstall): it is not added.
#[test]
fn a_complete_appdb_read_adds_no_folder_it_does_not_list() {
let got =
registered_only_additions(["PPSA00002"], &set(&["PPSA00002"]), &HashSet::new(), true);
assert!(got.is_empty());
}
#[test]
fn drops_the_console_page_tile() {
let rows = vec![