apple submit --build <n> (first submission, the resubmit steps; plan table with --json under serve); desktop owner actions to send for review: owner.apple_submit, owner.apple_resubmit, owner.apple_iap_submit, owner.play_production, and the price gate owner actions (owner.prices_play_products / _play_subscriptions / _apple_subscriptions; owner.prices_import passes --allow-price-changes after the dialog); OWNER_ALLOWED_ARGS ties each forbidden flag to its one action, dry runs never carry owner flags, MCP still has no submit or production release; submit dialog text in the host; system.tools androidSdk, project.inspect playRelease, declarations dataDeletion

This commit is contained in:
CRTkafa 2026-10-12 12:07:55 +08:00
parent 2e7dd44ae6
commit 33eb24a3dc
17 changed files with 335 additions and 49 deletions

View file

@ -236,7 +236,7 @@ pub fn tilde(text: &str, home: Option<&str>) -> String {
/// Human summary shown above the dry run in the confirm dialog.
pub fn dialog_summary(title: &str, description: &str, project: &str, network: bool, destructive: bool) -> String {
let mut s = format!("{title} · {project}\n\n{description}\n\n");
s.push_str(if network { "This changes your store records (not public until you submit or release in the console)." } else { "This changes local files only." });
s.push_str(if network { "This changes your store records (not public until you submit or release it)." } else { "This changes local files only." });
if destructive {
s.push_str("\nIt replaces what is there now.");
}
@ -248,6 +248,21 @@ pub fn owner_summary(title: &str, description: &str, project: &str) -> String {
format!("{title} · {project}\n\n{description}\n\nThis is your decision: it changes what users get on the store right away. storebound does it only because you press the button below.")
}
/// Summary of an owner action that sends the app to review or production (policy::SUBMIT_METHODS): what goes out and
/// that it may go live by itself once approved.
pub fn submit_summary(title: &str, description: &str, project: &str) -> String {
format!("{title} · {project}\n\n{description}\n\nThis is your decision: it sends the app to the store's review. Once approved it can go live without another click (App Store: the version's release setting; Google Play: with managed publishing off). storebound does it only because you press the button below.")
}
/// The dialog text above the dry run of one write or owner action.
pub fn summary_for(method: &str, kind: Kind, title: &str, description: &str, project: &str, network: bool, destructive: bool) -> String {
match kind {
Kind::Owner if policy::SUBMIT_METHODS.contains(&method) => submit_summary(title, description, project),
Kind::Owner => owner_summary(title, description, project),
_ => dialog_summary(title, description, project, network, destructive),
}
}
/// Dialog title and the confirm button of a write: owner actions name the action on the button.
pub fn dialog_labels(kind: Kind, title: &str) -> (String, String) {
if kind == Kind::Owner {
@ -340,7 +355,7 @@ pub async fn write_with_confirm(window: WebviewWindow, app: AppHandle, state: St
let (title, text, ok) = if method == policy::CONSOLE_FILL {
console_fill_dialog(&proj.name, &dry["data"])
} else {
let summary = if kind == Kind::Owner { owner_summary(&d.title, &d.description, &proj.name) } else { dialog_summary(&d.title, &d.description, &proj.name, d.network, d.destructive) };
let summary = summary_for(&method, kind, &d.title, &d.description, &proj.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);
(title, text, ok)

View file

@ -53,7 +53,8 @@ pub enum Kind {
Session,
/// Account-scoped store reads (apps.list, account.check): the host runs them with one account's key.
Account,
/// Owner actions (rollout share, phased release, sending one approved reply): like writes, a dry run from the
/// Owner actions (rollout share, phased release, sending one approved reply, submit for review, the Play production
/// release, price changes): like writes, a dry run from the
/// window; the real run only through `write_with_confirm`, whose dialog says that users see it at once.
Owner,
}
@ -106,6 +107,11 @@ pub fn sanitize_js_call(method: &str, kind: Option<Kind>, params: &Value) -> Res
Ok(Value::Object(out))
}
/// Owner actions that send the app to review or to production (cli/serve.ts OWNER_SUBMIT): App Store submit, resubmit
/// after a rejection, in-app purchases to App Review, the Play production release. Like every owner action they run
/// for real only through `write_with_confirm`; their dialog says that the app goes to review and may go live by itself.
pub const SUBMIT_METHODS: &[&str] = &["owner.apple_submit", "owner.apple_resubmit", "owner.apple_iap_submit", "owner.play_production"];
/// Write methods whose project is a new folder without a config (the host picks it in a native dialog and runs them
/// in its own commands: `project_init`, `project_import_fastlane`); `write_with_confirm` never runs them.
pub const NEW_FOLDER_METHODS: &[&str] = &["project.init", "import.fastlane"];

View file

@ -2,9 +2,9 @@
//! after a dialog that names the action on its button and says users see it at once. Tokens are bound to exactly the
//! method and the { project, args } sent (the same vector as the TypeScript side checks).
use serde_json::json;
use storebound_desktop::commands::{console_fill_dialog, dialog_labels, dialog_summary, owner_summary};
use storebound_desktop::commands::{console_fill_dialog, dialog_labels, dialog_summary, owner_summary, summary_for};
use storebound_desktop::confirm::{canonical, mac, mint};
use storebound_desktop::policy::{sanitize_js_call, Kind, CONSOLE_FILL, CONSOLE_FILL_NOTE};
use storebound_desktop::policy::{sanitize_js_call, Kind, CONSOLE_FILL, CONSOLE_FILL_NOTE, SUBMIT_METHODS};
#[test]
fn owner_actions_are_dry_runs_from_the_window() {
@ -29,6 +29,25 @@ fn owner_dialogs_name_the_action() {
assert!(dialog_summary("Push", "d", "A", true, false).contains("not public until you submit"));
}
#[test]
fn submit_for_review_is_an_owner_action_with_its_own_dialog() {
assert_eq!(SUBMIT_METHODS, &["owner.apple_submit", "owner.apple_resubmit", "owner.apple_iap_submit", "owner.play_production"]);
for m in SUBMIT_METHODS {
// a dry run from the window, whatever it sends; the real run only through write_with_confirm
let p = sanitize_js_call(m, Some(Kind::Owner), &json!({ "args": { "build": "52" } })).unwrap();
assert_eq!(p["dryRun"], json!(true));
for bad in [json!({ "dryRun": false }), json!({ "confirmToken": "sbt1.x" }), json!({ "args": {}, "project": "/x" })] {
assert!(sanitize_js_call(m, Some(Kind::Owner), &bad).is_err(), "{m} {bad}");
}
let s = summary_for(m, Kind::Owner, "Submit for App Review", "Sends the version.", "Prism Puddle", true, false);
assert!(s.contains("Prism Puddle") && s.contains("store's review") && s.contains("go live without another click") && s.contains("press the button"), "{s}");
}
// other owner actions keep their summary, writes theirs
assert!(summary_for("owner.play_rollout", Kind::Owner, "T", "d", "A", true, false).contains("right away"));
assert!(summary_for("play.listing", Kind::Write, "T", "d", "A", true, false).contains("not public until you submit"));
assert_eq!(dialog_labels(Kind::Owner, "Submit for App Review").1, "Yes: Submit for App Review");
}
#[test]
fn tokens_bind_method_project_and_args_of_each_write() {
let secret = [9u8; 32];
@ -41,6 +60,9 @@ fn tokens_bind_method_project_and_args_of_each_write() {
("owner.play_rollout", json!({ "action": "percent", "percent": 50 })),
("owner.ios_phased", json!({ "action": "resume", "version": "2.3.0" })),
("owner.reply_send", json!({ "file": "play-r-p1.md" })),
("owner.apple_submit", json!({ "build": "52", "version": "2.4.0" })),
("owner.apple_resubmit", json!({ "build": "53", "resolve_iaps": true })),
("owner.play_production", json!({ "version_code": "24", "status": "inProgress", "percent": 10 })),
];
for (method, args) in writes {
let bound = json!({ "project": "/apps/prism", "args": args });