store-only apps edit their listing on the store (storebound listing live / set: old -> new per field with the checks, a PATCH of only the edited fields; serve listing.set is the one store-only write, its token bound to the store ids; policy.rs STORE_ONLY_WRITES, write_with_confirm for store targets); screenshot captions per language and scene edited as a text edit of the shots copy JSON (JSON files keep their layout); Play content rating, target audience and App content answers in App info; docs/console-parity.md after the editors (73 rows: 55 complete, 18 with the reason) and tests/console-parity.test.ts keeping it honest

This commit is contained in:
CRTkafa 2026-10-14 00:53:38 +08:00
parent 3cea2af4e1
commit 7ac028421a
33 changed files with 852 additions and 157 deletions

View file

@ -193,8 +193,8 @@ pub async fn sidecar_call(window: WebviewWindow, app: AppHandle, state: State<'_
if matches!(kind, Some(Kind::Read | Kind::Write | Kind::Owner)) {
let id = project_id.ok_or_else(|| Fail::host(format!("{method} needs a project")))?;
if id.starts_with("store:") {
// a store-only app: store reads only, through the sidecar's generated config
if kind != Some(Kind::Read) {
// a store-only app: store reads, and the few writes that send only what the call carries (dry runs here)
if kind != Some(Kind::Read) && !policy::store_only_write(&method, kind) {
return Err(Fail::host("store-only apps are read-only: link a folder to change anything"));
}
let target = StoreTarget::parse(&id).map_err(Fail::host)?;
@ -205,7 +205,7 @@ pub async fn sidecar_call(window: WebviewWindow, app: AppHandle, state: State<'_
o.insert("credentials".into(), Value::Object(creds));
}
let res = r.call_job(&method, p, job.as_deref()).await?;
if readable(&res) {
if kind == Some(Kind::Read) && readable(&res) {
let _ = state.cache.put(&id, &method, &args, &res, &store::now_iso());
}
return Ok(res);
@ -400,6 +400,9 @@ pub async fn write_with_confirm(window: WebviewWindow, app: AppHandle, state: St
Value::Object(o) => Value::Object(o),
_ => return Err(Fail::host("args must be an object")),
};
if project_id.starts_with("store:") {
return store_write_with_confirm(&window, &app, &state, &r, &method, kind, &d, &project_id, args, job.as_deref()).await;
}
let (proj, creds) = project_params(&state, &project_id)?;
let mut dry_params = json!({ "project": proj.path, "args": args, "dryRun": true });
if !creds.is_empty() {
@ -431,6 +434,42 @@ pub async fn write_with_confirm(window: WebviewWindow, app: AppHandle, state: St
Ok(json!({ "confirmed": true, "stage": "done", "dryRun": dry, "result": result }))
}
/// `write_with_confirm` for a store-only app (no folder): only STORE_ONLY_WRITES, with the store ids (`virtual`) and the
/// accounts of the store target; the dry run, the same native dialog, then a token bound to `{ virtual, args }`.
#[allow(clippy::too_many_arguments)]
async fn store_write_with_confirm(window: &WebviewWindow, app: &AppHandle, state: &State<'_, AppState>, r: &std::sync::Arc<crate::sidecar::Running>, method: &str, kind: Kind, d: &crate::sidecar::Descriptor, project_id: &str, args: Value, job: Option<&str>) -> Res<Value> {
if !policy::store_only_write(method, Some(kind)) {
return Err(Fail::host("store-only apps are read-only for this: link a folder to change it"));
}
let target = StoreTarget::parse(project_id).map_err(Fail::host)?;
let creds = target_params(state, &target)?;
let virt = target.virtual_project();
let name = virt["name"].as_str().unwrap_or("store app").to_string();
let mut dry_params = json!({ "virtual": virt, "args": args, "dryRun": true });
if !creds.is_empty() {
dry_params["credentials"] = Value::Object(creds.clone());
}
let dry = r.call_job(method, dry_params, job).await?;
if dry["ok"] != Value::Bool(true) {
return Ok(json!({ "confirmed": false, "stage": "dry-run", "dryRun": dry }));
}
let summary = summary_for(method, kind, &d.title, &d.description, &name, d.network, d.destructive);
let text = format!("{summary}\n\nDry run ({}):\n{}", dry["command"].as_str().unwrap_or_default(), dialog_excerpt(dry["output"].as_str().unwrap_or_default()));
let (title, ok) = dialog_labels(kind, &d.title);
let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string());
if !confirm_dialog_with(window, title, tilde(&text, home.as_deref()), ok).await? {
return Ok(json!({ "confirmed": false, "stage": "dialog", "dryRun": dry }));
}
let bound = json!({ "virtual": virt, "args": args });
let token = r.mint(method, &bound).map_err(Fail::host)?;
let mut real = json!({ "virtual": virt, "args": args, "dryRun": false, "confirmToken": token });
if !creds.is_empty() {
real["credentials"] = Value::Object(creds);
}
let result = r.call_job(method, real, job).await?;
Ok(json!({ "confirmed": true, "stage": "done", "dryRun": dry, "result": result }))
}
/// An app folder as the window sees it: `missing` when the folder is gone (moved, renamed, deleted, or an old
/// worktree), so the app shows "Folder not found" with Locate / Remove instead of reading it.
#[derive(Debug, Clone, Serialize)]

View file

@ -122,6 +122,15 @@ pub const NEW_FOLDER_METHODS: &[&str] = &["project.init", "import.fastlane"];
pub const CONSOLE_FILL: &str = "console.fill";
pub const CONSOLE_FILL_NOTE: &str = "Console pages change. This is AI-assisted: review every field in the browser and press the final button (Publish, Submit, Save or Create) yourself. storebound moves through the pages for you and never submits or publishes; App Privacy answers stay a draft until you press Publish.";
/// Writes a store-only app (no folder) may run: they send only what the call carries (one language's texts), never
/// the sidecar's generated config. Dry run from the window like every write; the real run only through
/// `write_with_confirm`, its token bound to the store ids (`virtual`) instead of a folder.
pub const STORE_ONLY_WRITES: &[&str] = &["listing.set"];
pub fn store_only_write(method: &str, kind: Option<Kind>) -> bool {
kind == Some(Kind::Write) && STORE_ONLY_WRITES.contains(&method)
}
/// Reads that are never kept in the offline cache (big: one rendered image as a data: URL).
pub const NO_CACHE_METHODS: &[&str] = &["project.render"];