mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
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:
commit
7d510c0f06
16 files changed
+1130
-99
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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![
|
||||
|
||||
Reference in new issue
Block a user