1555 lines
74 KiB
Rust
1555 lines
74 KiB
Rust
//! App commands. Each one first checks the calling window and page (policy::require_main). Secrets never cross into
|
|
//! the webview: account files are picked in native dialogs and read here, and credentials go from the machine's
|
|
//! credential profiles (profiles.rs, shared with the CLI) straight into the sidecar call.
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
|
|
use serde::Serialize;
|
|
use serde_json::{json, Map, Value};
|
|
use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow};
|
|
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
|
use tauri_plugin_opener::OpenerExt;
|
|
|
|
use crate::accounts::{self, Account, AccountKind, Secret, SecretStore};
|
|
use crate::ai_fix;
|
|
use crate::profiles::{self, ProfileStore};
|
|
use crate::registry;
|
|
use crate::cache::{Cache, Entry};
|
|
use crate::downloads;
|
|
use crate::policy::{self, Kind};
|
|
use crate::sidecar::{valid_job, Fail, Progress, Sidecar};
|
|
use crate::store::{self, Project, State as Saved};
|
|
use crate::updates;
|
|
use crate::workspace::{self, Overrides, Settings, StoreTarget, Studio};
|
|
|
|
pub struct AppState {
|
|
pub sidecar: Sidecar,
|
|
pub data_dir: PathBuf,
|
|
pub saved: Mutex<Saved>,
|
|
/// The machine's credential profiles (~/.storebound/credentials, shared with the CLI): where keys live.
|
|
pub profiles: ProfileStore,
|
|
/// Keychain entries of versions before 0.8, moved into profiles once by `sync_machine`.
|
|
pub legacy: Box<dyn SecretStore>,
|
|
/// The machine's app folder list (~/.storebound/projects.json, shared with the CLI).
|
|
pub registry: PathBuf,
|
|
pub cache: Cache,
|
|
pub downloads: downloads::Jobs,
|
|
}
|
|
|
|
type Res<T> = Result<T, Fail>;
|
|
|
|
fn guard(w: &WebviewWindow) -> Res<()> {
|
|
let url = w.url().map_err(|e| Fail::host(e.to_string()))?;
|
|
policy::require_main(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
|
|
}
|
|
|
|
fn saved(s: &AppState) -> Res<std::sync::MutexGuard<'_, Saved>> {
|
|
s.saved.lock().map_err(|_| Fail::host("state poisoned"))
|
|
}
|
|
|
|
fn persist(s: &AppState, st: &Saved) -> Res<()> {
|
|
store::save(&s.data_dir, st).map_err(Fail::host)
|
|
}
|
|
|
|
/// Brings the state in line with the machine's shared stores: accounts with the credential profiles (and moves old
|
|
/// keychain entries into profiles), the folder list with the registry. Logged without values; saved when it changed.
|
|
pub fn sync_machine(s: &AppState) -> Res<()> {
|
|
let mut st = saved(s)?;
|
|
let report = profiles::sync(&mut st.accounts, &s.profiles, s.legacy.as_ref(), &store::now_iso());
|
|
for m in &report.migrated {
|
|
crate::logs::host("info", &format!("account moved from the keychain into a credential profile: {m}"));
|
|
}
|
|
for a in &report.added {
|
|
crate::logs::host("info", &format!("account from a credential profile: {a}"));
|
|
}
|
|
for n in &report.notes {
|
|
crate::logs::host("warn", n);
|
|
}
|
|
let folders = match registry::sync(&mut st, &s.registry) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
crate::logs::host("warn", &format!("app folder list not synced: {e}"));
|
|
false
|
|
}
|
|
};
|
|
if report.changed() || folders {
|
|
persist(s, &st)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// After the app changed its folder list: the shared registry gets it (keeping folders another tool added).
|
|
fn publish_folders(s: &AppState, st: &Saved, removed: &[String]) {
|
|
if let Err(e) = registry::publish(st, &s.registry, removed) {
|
|
crate::logs::host("warn", &format!("app folder list not written: {e}"));
|
|
}
|
|
}
|
|
|
|
/// One account's keys, read from its credential profile right now.
|
|
fn secret_of(s: &AppState, id: &str) -> Result<Secret, String> {
|
|
let (profile, kind) = {
|
|
let st = s.saved.lock().map_err(|_| "state poisoned".to_string())?;
|
|
let a = st.accounts.iter().find(|a| a.id == id).ok_or("unknown account")?;
|
|
(a.profile.clone().ok_or("the account's key has not moved from the keychain yet: restart storebound, or add the key again")?, a.kind)
|
|
};
|
|
s.profiles.secret(&profile, kind)
|
|
}
|
|
|
|
fn job_of(job: Option<String>) -> Res<Option<String>> {
|
|
match job {
|
|
Some(j) if !valid_job(&j) => Err(Fail::host("bad job id")),
|
|
j => Ok(j),
|
|
}
|
|
}
|
|
|
|
/// Project folder + credentials of its mapped accounts, as sidecar params.
|
|
fn project_params(s: &AppState, project_id: &str) -> Res<(Project, Map<String, Value>)> {
|
|
let p = saved(s)?.projects.iter().find(|p| p.id == project_id).cloned().ok_or_else(|| Fail::host("unknown project"))?;
|
|
let mut creds = Map::new();
|
|
for id in [&p.play_account, &p.apple_account].into_iter().flatten() {
|
|
// A missing key is not fatal: the command says which credential it lacks.
|
|
if let Ok(secret) = secret_of(s, id) {
|
|
secret.to_credentials(&mut creds);
|
|
}
|
|
}
|
|
Ok((p, creds))
|
|
}
|
|
|
|
/// Credentials of the accounts a store-only app is read with; each part must use an account of its store.
|
|
fn target_params(s: &AppState, t: &StoreTarget) -> Res<Map<String, Value>> {
|
|
let st = saved(s)?;
|
|
let kind = |id: &str| st.accounts.iter().find(|a| a.id == id).map(|a| a.kind);
|
|
if let Some((acc, ..)) = &t.ios {
|
|
if kind(acc) != Some(AccountKind::Apple) {
|
|
return Err(Fail::host("store app: not an App Store Connect account"));
|
|
}
|
|
}
|
|
if let Some((acc, _)) = &t.android {
|
|
if kind(acc) != Some(AccountKind::Play) {
|
|
return Err(Fail::host("store app: not a Google Play account"));
|
|
}
|
|
}
|
|
drop(st);
|
|
let mut creds = Map::new();
|
|
for id in t.accounts() {
|
|
if let Ok(secret) = secret_of(s, id) {
|
|
secret.to_credentials(&mut creds);
|
|
}
|
|
}
|
|
Ok(creds)
|
|
}
|
|
|
|
/// One account's credentials only (account reads).
|
|
fn account_creds(s: &AppState, id: &str) -> Res<Map<String, Value>> {
|
|
if !saved(s)?.accounts.iter().any(|a| a.id == id) {
|
|
return Err(Fail::host("unknown account"));
|
|
}
|
|
let mut creds = Map::new();
|
|
secret_of(s, id).map_err(Fail::host)?.to_credentials(&mut creds);
|
|
Ok(creds)
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct AppInfo {
|
|
version: &'static str,
|
|
identifier: String,
|
|
platform: &'static str,
|
|
debug: bool,
|
|
/// Shown as "~" in paths, so screenshots do not carry the account name.
|
|
home: Option<String>,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn app_info(window: WebviewWindow, app: AppHandle) -> Res<AppInfo> {
|
|
guard(&window)?;
|
|
Ok(AppInfo {
|
|
version: env!("CARGO_PKG_VERSION"),
|
|
identifier: app.config().identifier.clone(),
|
|
platform: std::env::consts::OS,
|
|
debug: cfg!(debug_assertions),
|
|
home: app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string()),
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn sidecar_status(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>) -> Res<Value> {
|
|
guard(&window)?;
|
|
let r = state.sidecar.get(&app).await?;
|
|
let tools = r.call("system.tools", json!({})).await.unwrap_or(Value::Null);
|
|
Ok(json!({ "running": r.alive(), "version": r.version, "protocol": r.protocol, "methods": r.methods, "tools": tools }))
|
|
}
|
|
|
|
/// A read (or dry-run write) through the sidecar. Writes from here are always dry runs (policy::sanitize_js_call).
|
|
/// Successful reads of a project are kept in the offline cache (results only, never the credentials).
|
|
#[tauri::command]
|
|
pub async fn sidecar_call(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, method: String, project_id: Option<String>, params: Option<Value>, job: Option<String>) -> Res<Value> {
|
|
guard(&window)?;
|
|
let job = job_of(job)?;
|
|
let r = state.sidecar.get(&app).await?;
|
|
let kind = r.kind(&method);
|
|
let mut p = policy::sanitize_js_call(&method, kind, ¶ms.unwrap_or(Value::Null)).map_err(Fail::host)?;
|
|
let args = p.get("args").cloned().unwrap_or_else(|| json!({}));
|
|
let mut cache_project = None;
|
|
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, 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)?;
|
|
let creds = target_params(&state, &target)?;
|
|
let o = p.as_object_mut().ok_or_else(|| Fail::host("params"))?;
|
|
o.insert("virtual".into(), target.virtual_project());
|
|
if !creds.is_empty() {
|
|
o.insert("credentials".into(), Value::Object(creds));
|
|
}
|
|
let res = r.call_job(&method, p, job.as_deref()).await?;
|
|
if kind == Some(Kind::Read) && readable(&res) {
|
|
let _ = state.cache.put(&id, &method, &args, &res, &store::now_iso());
|
|
}
|
|
return Ok(res);
|
|
}
|
|
let (proj, creds) = project_params(&state, &id)?;
|
|
let o = p.as_object_mut().ok_or_else(|| Fail::host("params"))?;
|
|
o.insert("project".into(), Value::String(proj.path));
|
|
if !creds.is_empty() && method != "project.inspect" {
|
|
o.insert("credentials".into(), Value::Object(creds));
|
|
}
|
|
if kind == Some(Kind::Read) && !policy::NO_CACHE_METHODS.contains(&method.as_str()) {
|
|
cache_project = Some(id);
|
|
}
|
|
}
|
|
let res = r.call_job(&method, p, job.as_deref()).await?;
|
|
if let Some(id) = cache_project {
|
|
// only results that read something: a failed store read must not hide the last good one
|
|
if readable(&res) {
|
|
let _ = state.cache.put(&id, &method, &args, &res, &store::now_iso());
|
|
}
|
|
}
|
|
Ok(res)
|
|
}
|
|
|
|
/// A result worth caching: it carries data and is ok, or "findings" (scan / check / validate / health exit 1 with their
|
|
/// result printed: by design, not a failure). A failed read must not hide the last good one.
|
|
fn readable(res: &Value) -> bool {
|
|
!res["data"].is_null() && (res["ok"] == Value::Bool(true) || res["outcome"] == Value::String("findings".into()))
|
|
}
|
|
|
|
/// The last cached result of a read (`project_id` "*" for the portfolio), with its time; null when never read.
|
|
#[tauri::command]
|
|
pub fn cache_get(window: WebviewWindow, state: State<'_, AppState>, method: String, project_id: String, args: Option<Value>) -> Res<Option<Entry>> {
|
|
guard(&window)?;
|
|
state.cache.get(&project_id, &method, &args.unwrap_or_else(|| json!({}))).map_err(Fail::host)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn cache_clear(window: WebviewWindow, state: State<'_, AppState>) -> Res<()> {
|
|
guard(&window)?;
|
|
state.cache.clear().map_err(Fail::host)
|
|
}
|
|
|
|
/// Stops a running job: its sidecar requests (`$/cancelRequest`) and a running download.
|
|
#[tauri::command]
|
|
pub async fn job_cancel(window: WebviewWindow, state: State<'_, AppState>, job: String) -> Res<bool> {
|
|
guard(&window)?;
|
|
if !valid_job(&job) {
|
|
return Err(Fail::host("bad job id"));
|
|
}
|
|
let mut hit = state.downloads.cancel(&job) | ai_fix::cancel(&job);
|
|
if let Some(r) = state.sidecar.current().await {
|
|
hit |= r.cancel_job(&job) > 0;
|
|
hit |= r.cancel_job(&format!("{job}:p")) > 0;
|
|
}
|
|
Ok(hit)
|
|
}
|
|
|
|
const DIALOG_LINES: usize = 28;
|
|
const DIALOG_CHARS: usize = 2_400;
|
|
|
|
/// Last lines of a dry run for the dialog (native alerts do not scroll well).
|
|
pub fn dialog_excerpt(output: &str) -> String {
|
|
let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
|
|
let start = lines.len().saturating_sub(DIALOG_LINES);
|
|
let mut s = lines[start..].join("\n");
|
|
if s.chars().count() > DIALOG_CHARS {
|
|
s = s.chars().rev().take(DIALOG_CHARS).collect::<Vec<_>>().into_iter().rev().collect();
|
|
s = format!("...{s}");
|
|
}
|
|
if start > 0 {
|
|
s = format!("({start} earlier lines in the app log)\n{s}");
|
|
}
|
|
s
|
|
}
|
|
|
|
/// The home folder as "~" in dialog text, so a screenshot or screen share does not carry the account name.
|
|
pub fn tilde(text: &str, home: Option<&str>) -> String {
|
|
match home.map(|h| h.trim_end_matches(['/', '\\'])) {
|
|
Some(h) if h.len() > 1 => text.replace(h, "~"),
|
|
_ => text.to_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 it)." } else { "This changes local files only." });
|
|
if destructive {
|
|
s.push_str("\nIt replaces what is there now.");
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Summary of an owner action: users see it right away, so the dialog says so in plain words.
|
|
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 {
|
|
(format!("Owner action: {title}"), format!("Yes: {title}"))
|
|
} else {
|
|
(format!("Apply: {title}"), "Apply".to_string())
|
|
}
|
|
}
|
|
|
|
/// Dialog of `console.fill` (title, text, button): the answers of its dry run (`data` of `console fill --dry --json`),
|
|
/// the uncertain ones marked for the owner, and the note that the owner reviews and saves in the browser.
|
|
pub fn console_fill_dialog(project: &str, data: &Value) -> (String, String, String) {
|
|
let title = data["title"].as_str().unwrap_or("Console form");
|
|
let mut s = format!("{title} · {project}\n\nA browser window opens on the console page. It uses storebound's own browser profile: the first time, sign in there yourself (storebound never types passwords); the sign-in is kept for the next runs. Your own Chrome's cookies are never copied (Google ties them to your device and could sign you out). storebound opens the form, fills the fields below and marks the ones you answer.\n\n{}\n", policy::CONSOLE_FILL_NOTE);
|
|
let answers = data["answers"].as_array().cloned().unwrap_or_default();
|
|
let mut lines: Vec<String> = answers
|
|
.iter()
|
|
.map(|a| {
|
|
let label = a["label"].as_str().unwrap_or_default();
|
|
if a["certain"].as_bool() == Some(true) {
|
|
format!("- {label}: {}", a["value"].as_str().unwrap_or_default())
|
|
} else {
|
|
format!("- {label}: you answer this")
|
|
}
|
|
})
|
|
.collect();
|
|
if lines.is_empty() {
|
|
lines.push("- (no answers from the config: every field is yours)".into());
|
|
}
|
|
let mut list = String::new();
|
|
for (i, l) in lines.iter().enumerate() {
|
|
if list.chars().count() + l.chars().count() > DIALOG_CHARS {
|
|
list.push_str(&format!("({} more in the app)\n", lines.len() - i));
|
|
break;
|
|
}
|
|
list.push_str(l);
|
|
list.push('\n');
|
|
}
|
|
s.push_str(&format!("\nAnswers ({} for you):\n{list}", data["uncertain"].as_u64().unwrap_or(0)));
|
|
(format!("Fill in console: {title}"), s, "Open the browser".to_string())
|
|
}
|
|
|
|
/// Native Apply / Cancel dialog; true on Apply.
|
|
async fn confirm_dialog(window: &WebviewWindow, title: String, text: String) -> Res<bool> {
|
|
confirm_dialog_with(window, title, text, "Apply".into()).await
|
|
}
|
|
|
|
async fn confirm_dialog_with(window: &WebviewWindow, title: String, text: String, ok: String) -> Res<bool> {
|
|
let w = window.clone();
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
w.app_handle()
|
|
.dialog()
|
|
.message(text)
|
|
.title(title)
|
|
.kind(MessageDialogKind::Warning)
|
|
.buttons(MessageDialogButtons::OkCancelCustom(ok, "Cancel".into()))
|
|
.parent(&w)
|
|
.blocking_show()
|
|
})
|
|
.await
|
|
.map_err(|e| Fail::host(e.to_string()))
|
|
}
|
|
|
|
/// Dry run, then a native dialog with the summary and the dry run, then (only on Apply) the real run with a confirm
|
|
/// token minted here for exactly this method, project and arguments.
|
|
#[tauri::command]
|
|
pub async fn write_with_confirm(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, method: String, project_id: String, args: Option<Value>, job: Option<String>) -> Res<Value> {
|
|
guard(&window)?;
|
|
let job = job_of(job)?;
|
|
let r = state.sidecar.get(&app).await?;
|
|
let kind = r.kind(&method);
|
|
if !matches!(kind, Some(Kind::Write | Kind::Owner)) || policy::NEW_FOLDER_METHODS.contains(&method.as_str()) {
|
|
return Err(Fail::host(format!("{method} is not a write method of an app folder")));
|
|
}
|
|
let kind = kind.unwrap_or(Kind::Write);
|
|
let d = r.descriptor(&method).cloned().ok_or_else(|| Fail::host("unknown method"))?;
|
|
let args = match args.unwrap_or(json!({})) {
|
|
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() {
|
|
dry_params["credentials"] = Value::Object(creds.clone());
|
|
}
|
|
let dry = r.call_job(&method, dry_params, job.as_deref()).await?;
|
|
if dry["ok"] != Value::Bool(true) {
|
|
return Ok(json!({ "confirmed": false, "stage": "dry-run", "dryRun": dry }));
|
|
}
|
|
let (title, text, ok) = if method == policy::CONSOLE_FILL {
|
|
console_fill_dialog(&proj.name, &dry["data"])
|
|
} else {
|
|
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)
|
|
};
|
|
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!({ "project": proj.path, "args": args });
|
|
let token = r.mint(&method, &bound).map_err(Fail::host)?;
|
|
let mut real = json!({ "project": proj.path, "args": args, "dryRun": false, "confirmToken": token });
|
|
if !creds.is_empty() {
|
|
real["credentials"] = Value::Object(creds);
|
|
}
|
|
let result = r.call_job(&method, real, job.as_deref()).await?;
|
|
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)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProjectView {
|
|
#[serde(flatten)]
|
|
pub project: Project,
|
|
pub missing: bool,
|
|
/// The storebound version the folder's package.json pins when it is not this app's (its bundled CLI): the window
|
|
/// says "project uses storebound X, app is Y".
|
|
pub storebound_pin: Option<String>,
|
|
}
|
|
|
|
pub fn project_view(p: &Project) -> ProjectView {
|
|
let dir = Path::new(&p.path);
|
|
ProjectView { project: p.clone(), missing: !dir.is_dir(), storebound_pin: workspace::pinned_storebound(dir).filter(|pin| workspace::pin_differs(pin, env!("CARGO_PKG_VERSION"))) }
|
|
}
|
|
|
|
/// The app folders: the machine's shared list (registry.rs), with the app's accounts per folder.
|
|
#[tauri::command]
|
|
pub fn projects_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<ProjectView>> {
|
|
guard(&window)?;
|
|
sync_machine(&state)?;
|
|
Ok(saved(&state)?.projects.iter().map(project_view).collect())
|
|
}
|
|
|
|
/// "Locate…" for a folder that moved: native folder picker; the folder must hold a storebound.config.ts and not be in
|
|
/// the list already. Keeps the app's id, accounts, links and saved reads. None = cancelled.
|
|
#[tauri::command]
|
|
pub async fn project_locate(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, id: String) -> Res<Option<ProjectView>> {
|
|
guard(&window)?;
|
|
if !saved(&state)?.projects.iter().any(|p| p.id == id) {
|
|
return Err(Fail::host("unknown project"));
|
|
}
|
|
let Some(dir) = pick_folder(&window, "Locate the app folder (with storebound.config.ts)").await? else { return Ok(None) };
|
|
if !has_config(&dir) {
|
|
return Err(Fail::host(format!("no storebound.config.ts in {}", dir.display())));
|
|
}
|
|
let path = dir.to_string_lossy().to_string();
|
|
// the app's name from its config when the sidecar can read it; the old name otherwise
|
|
let name = match state.sidecar.get(&app).await {
|
|
Ok(r) => r.call("project.inspect", json!({ "project": path })).await.ok().and_then(|v| v["name"].as_str().map(str::to_string)),
|
|
Err(_) => None,
|
|
};
|
|
let mut st = saved(&state)?;
|
|
if st.projects.iter().any(|p| p.path == path && p.id != id) {
|
|
return Err(Fail::host("this folder is already in the list"));
|
|
}
|
|
let p = st.projects.iter_mut().find(|p| p.id == id).ok_or_else(|| Fail::host("unknown project"))?;
|
|
p.path = path;
|
|
if let Some(n) = name.filter(|n| !n.trim().is_empty()) {
|
|
p.name = n;
|
|
}
|
|
let view = project_view(p);
|
|
persist(&state, &st)?;
|
|
publish_folders(&state, &st, &[]);
|
|
crate::logs::host("info", &format!("app folder located: {}", view.project.path));
|
|
Ok(Some(view))
|
|
}
|
|
|
|
const CONFIG_NAMES: [&str; 3] = ["storebound.config.ts", "storebound.config.js", "storebound.config.json"];
|
|
|
|
pub fn has_config(dir: &Path) -> bool {
|
|
CONFIG_NAMES.iter().any(|n| dir.join(n).is_file())
|
|
}
|
|
|
|
async fn pick_folder(window: &WebviewWindow, title: &'static str) -> Res<Option<PathBuf>> {
|
|
let w = window.clone();
|
|
let picked = tauri::async_runtime::spawn_blocking(move || w.app_handle().dialog().file().set_title(title).set_parent(&w).blocking_pick_folder())
|
|
.await
|
|
.map_err(|e| Fail::host(e.to_string()))?;
|
|
let Some(picked) = picked else { return Ok(None) };
|
|
let dir = picked.into_path().map_err(|e| Fail::host(e.to_string()))?;
|
|
Ok(Some(std::fs::canonicalize(&dir).unwrap_or(dir)))
|
|
}
|
|
|
|
/// Adds a folder that has a config (inspected by the sidecar) to the list.
|
|
async fn add_folder(app: &AppHandle, state: &AppState, dir: &Path) -> Res<Project> {
|
|
let path = dir.to_string_lossy().to_string();
|
|
if saved(state)?.projects.iter().any(|p| p.path == path) {
|
|
return Err(Fail::host("this folder is already in the list"));
|
|
}
|
|
let r = state.sidecar.get(app).await?;
|
|
let summary = r.call("project.inspect", json!({ "project": path })).await?;
|
|
let project = Project {
|
|
id: store::new_id()?,
|
|
path,
|
|
name: summary["name"].as_str().unwrap_or("?").to_string(),
|
|
play_account: None,
|
|
apple_account: None,
|
|
added: store::now_iso(),
|
|
};
|
|
let mut st = saved(state)?;
|
|
st.projects.push(project.clone());
|
|
persist(state, &st)?;
|
|
publish_folders(state, &st, &[]);
|
|
Ok(project)
|
|
}
|
|
|
|
/// Native folder picker; the folder must contain storebound.config.ts and load (project.inspect). None = cancelled.
|
|
#[tauri::command]
|
|
pub async fn project_add(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>) -> Res<Option<Project>> {
|
|
guard(&window)?;
|
|
let Some(dir) = pick_folder(&window, "Add an app folder (with storebound.config.ts)").await? else { return Ok(None) };
|
|
if !has_config(&dir) {
|
|
return Err(Fail::host(format!("no storebound.config.ts in {}", dir.display())));
|
|
}
|
|
add_folder(&app, &state, &dir).await.map(Some)
|
|
}
|
|
|
|
pub const PROFILES: [&str; 3] = ["app", "game", "subscription-app"];
|
|
|
|
/// `storebound init` for a new app folder: native folder picker, the dry run in a native dialog, then the templates
|
|
/// for `profile` are written (existing files kept) and the folder is added. None = cancelled at any step.
|
|
#[tauri::command]
|
|
pub async fn project_init(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, profile: String) -> Res<Option<Project>> {
|
|
guard(&window)?;
|
|
if !PROFILES.contains(&profile.as_str()) {
|
|
return Err(Fail::host("profile: app, game or subscription-app"));
|
|
}
|
|
let Some(dir) = pick_folder(&window, "Choose the app's folder (storebound files go into it)").await? else { return Ok(None) };
|
|
if has_config(&dir) {
|
|
return Err(Fail::host("this folder already has a storebound config: add it instead"));
|
|
}
|
|
let path = dir.to_string_lossy().to_string();
|
|
let args = json!({ "profile": profile });
|
|
let r = state.sidecar.get(&app).await?;
|
|
let dry = r.call("project.init", json!({ "project": path, "args": args, "dryRun": true })).await?;
|
|
if dry["ok"] != Value::Bool(true) {
|
|
return Err(Fail::host(format!("init dry run failed: {}", dialog_excerpt(dry["output"].as_str().unwrap_or_default()))));
|
|
}
|
|
let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string());
|
|
let text = format!(
|
|
"Start an app folder · {profile} profile\n\n{path}\n\nCopies the storebound templates into this folder; existing files are kept. Every answer in them is a starting point to edit.\n\n{}",
|
|
dialog_excerpt(dry["output"].as_str().unwrap_or_default())
|
|
);
|
|
if !confirm_dialog(&window, "Apply: Start an app folder".into(), tilde(&text, home.as_deref())).await? {
|
|
return Ok(None);
|
|
}
|
|
let token = r.mint("project.init", &json!({ "project": path, "args": args })).map_err(Fail::host)?;
|
|
let res = r.call("project.init", json!({ "project": path, "args": args, "dryRun": false, "confirmToken": token })).await?;
|
|
if res["ok"] != Value::Bool(true) {
|
|
return Err(Fail::host(format!("init failed: {}", dialog_excerpt(res["output"].as_str().unwrap_or_default()))));
|
|
}
|
|
add_folder(&app, &state, &dir).await.map(Some)
|
|
}
|
|
|
|
/// `storebound import fastlane` for a folder with a fastlane setup and no storebound config yet: native folder picker,
|
|
/// the dry run (the import plan and the report of what it does not map) in a native dialog, then the files are written
|
|
/// (fastlane stays untouched, nothing is overwritten) and the folder is added. None = cancelled at any step.
|
|
#[tauri::command]
|
|
pub async fn project_import_fastlane(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, profile: Option<String>) -> Res<Option<Project>> {
|
|
guard(&window)?;
|
|
if let Some(p) = &profile {
|
|
if !PROFILES.contains(&p.as_str()) {
|
|
return Err(Fail::host("profile: app, game or subscription-app"));
|
|
}
|
|
}
|
|
let Some(dir) = pick_folder(&window, "Choose the app folder that has the fastlane folder").await? else { return Ok(None) };
|
|
if has_config(&dir) {
|
|
return Err(Fail::host("this folder already has a storebound config: add it instead"));
|
|
}
|
|
let path = dir.to_string_lossy().to_string();
|
|
let args = match &profile {
|
|
Some(p) => json!({ "profile": p }),
|
|
None => json!({}),
|
|
};
|
|
let r = state.sidecar.get(&app).await?;
|
|
let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string());
|
|
let dry = r.call("import.fastlane", json!({ "project": path, "args": args, "dryRun": true })).await?;
|
|
if dry["ok"] != Value::Bool(true) {
|
|
return Err(Fail::host(tilde(&format!("fastlane import: {}", dialog_excerpt(dry["output"].as_str().unwrap_or_default())), home.as_deref())));
|
|
}
|
|
let text = format!(
|
|
"Import from fastlane\n\n{path}\n\nReads the fastlane folder (Appfile, deliver and supply metadata, screenshots) and writes storebound.config.ts, the listing files and screenshot frames next to it. fastlane stays untouched and no existing file is overwritten. Keys, team ids and the App Review contact are only named, never copied.\n\nDry run:\n{}",
|
|
dialog_excerpt(dry["output"].as_str().unwrap_or_default())
|
|
);
|
|
if !confirm_dialog(&window, "Apply: Import from fastlane".into(), tilde(&text, home.as_deref())).await? {
|
|
return Ok(None);
|
|
}
|
|
let token = r.mint("import.fastlane", &json!({ "project": path, "args": args })).map_err(Fail::host)?;
|
|
let res = r.call("import.fastlane", json!({ "project": path, "args": args, "dryRun": false, "confirmToken": token })).await?;
|
|
if res["ok"] != Value::Bool(true) {
|
|
return Err(Fail::host(tilde(&format!("fastlane import failed: {}", dialog_excerpt(res["output"].as_str().unwrap_or_default())), home.as_deref())));
|
|
}
|
|
if !has_config(&dir) {
|
|
return Err(Fail::host("the import wrote no storebound config"));
|
|
}
|
|
add_folder(&app, &state, &dir).await.map(Some)
|
|
}
|
|
|
|
/// Saves an export the window made (prices CSV, listings, reviews, reports) where the owner picks in a native save
|
|
/// dialog (default: Downloads). Only text, only the known kinds, at most EXPORT_MAX_BYTES. None = cancelled; else the
|
|
/// path written, with the home folder as "~".
|
|
#[tauri::command]
|
|
pub async fn export_save(window: WebviewWindow, app: AppHandle, name: String, kind: String, content: String, personal: Option<bool>) -> Res<Option<String>> {
|
|
guard(&window)?;
|
|
let file = policy::export_file_name(&name, &kind).ok_or_else(|| Fail::host("export: unknown kind or empty name"))?;
|
|
if content.len() > policy::EXPORT_MAX_BYTES {
|
|
return Err(Fail::host("export: too big"));
|
|
}
|
|
// reviewer names and review texts: say where they may go before the file exists
|
|
if personal == Some(true) && !confirm_dialog_with(&window, "Personal data".into(), policy::PERSONAL_EXPORT_NOTE.into(), "Save on this computer".into()).await? {
|
|
return Ok(None);
|
|
}
|
|
let w = window.clone();
|
|
let dir = app.path().download_dir().ok();
|
|
let ext = kind.clone();
|
|
let picked = tauri::async_runtime::spawn_blocking(move || {
|
|
let mut d = w.app_handle().dialog().file().set_title("Export").set_file_name(&file).add_filter(ext.to_uppercase(), &[ext.as_str()]).set_parent(&w);
|
|
if let Some(dir) = dir {
|
|
d = d.set_directory(dir);
|
|
}
|
|
d.blocking_save_file()
|
|
})
|
|
.await
|
|
.map_err(|e| Fail::host(e.to_string()))?;
|
|
let Some(picked) = picked else { return Ok(None) };
|
|
let path = picked.into_path().map_err(|e| Fail::host(e.to_string()))?;
|
|
std::fs::write(&path, content.as_bytes()).map_err(|e| Fail::host(format!("export: {e}")))?;
|
|
let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string());
|
|
Ok(Some(tilde(&path.to_string_lossy(), home.as_deref())))
|
|
}
|
|
|
|
/// A price sheet (CSV) to compare with the stores, picked in a native dialog. None = cancelled. The path goes back
|
|
/// to the window only as the `csv` argument of prices.diff / owner.prices_import (both read it, neither writes it).
|
|
#[tauri::command]
|
|
pub async fn price_sheet_pick(window: WebviewWindow, state: State<'_, AppState>, project_id: String) -> Res<Option<String>> {
|
|
guard(&window)?;
|
|
let start = saved(&state)?.projects.iter().find(|p| p.id == project_id).map(|p| PathBuf::from(&p.path)).ok_or_else(|| Fail::host("unknown project"))?;
|
|
let w = window.clone();
|
|
let picked = tauri::async_runtime::spawn_blocking(move || w.app_handle().dialog().file().set_title("Choose a price sheet (CSV)").add_filter("CSV", &["csv"]).set_directory(start).set_parent(&w).blocking_pick_file())
|
|
.await
|
|
.map_err(|e| Fail::host(e.to_string()))?;
|
|
let Some(picked) = picked else { return Ok(None) };
|
|
let p = picked.into_path().map_err(|e| Fail::host(e.to_string()))?;
|
|
if !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("csv")) {
|
|
return Err(Fail::host("a price sheet is a .csv file"));
|
|
}
|
|
Ok(Some(p.to_string_lossy().to_string()))
|
|
}
|
|
|
|
/// An image (PNG / JPEG) to replace one store image with, picked in a native dialog. None = cancelled. The path goes
|
|
/// back to the window only as the `from` argument of art.replace (a write: dry run, dialog, then the copy into the app
|
|
/// folder).
|
|
#[tauri::command]
|
|
pub async fn image_pick(window: WebviewWindow, state: State<'_, AppState>, project_id: String) -> Res<Option<String>> {
|
|
guard(&window)?;
|
|
let start = saved(&state)?.projects.iter().find(|p| p.id == project_id).map(|p| PathBuf::from(&p.path)).ok_or_else(|| Fail::host("unknown project"))?;
|
|
let w = window.clone();
|
|
let picked = tauri::async_runtime::spawn_blocking(move || w.app_handle().dialog().file().set_title("Choose an image (PNG or JPEG)").add_filter("Image", &["png", "jpg", "jpeg"]).set_directory(start).set_parent(&w).blocking_pick_file())
|
|
.await
|
|
.map_err(|e| Fail::host(e.to_string()))?;
|
|
let Some(picked) = picked else { return Ok(None) };
|
|
let p = picked.into_path().map_err(|e| Fail::host(e.to_string()))?;
|
|
if !p.extension().is_some_and(|e| ["png", "jpg", "jpeg"].iter().any(|x| e.eq_ignore_ascii_case(x))) {
|
|
return Err(Fail::host("pick a .png or .jpg file"));
|
|
}
|
|
Ok(Some(p.to_string_lossy().to_string()))
|
|
}
|
|
|
|
/// Shows a folder or file of an app folder in the file manager (`rel` empty: the app folder), or opens it with
|
|
/// `open` (a folder, or the gallery page storebound writes: <renders>/index.html in the default browser).
|
|
#[tauri::command]
|
|
pub fn project_reveal(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, project_id: String, rel: Option<String>, open: Option<bool>) -> Res<()> {
|
|
guard(&window)?;
|
|
let root = saved(&state)?.projects.iter().find(|p| p.id == project_id).map(|p| PathBuf::from(&p.path)).ok_or_else(|| Fail::host("unknown project"))?;
|
|
let rel = rel.unwrap_or_default();
|
|
let (target, relp) = if rel.is_empty() {
|
|
(root.clone(), PathBuf::new())
|
|
} else {
|
|
let r = policy::project_relative(&rel).ok_or_else(|| Fail::host("not a path inside the app folder"))?;
|
|
(root.join(&r), r)
|
|
};
|
|
let real = std::fs::canonicalize(&target).map_err(|_| Fail::host(format!("{rel}: not there yet")))?;
|
|
let real_root = std::fs::canonicalize(&root).map_err(|e| Fail::host(e.to_string()))?;
|
|
if !real.starts_with(&real_root) {
|
|
return Err(Fail::host("not a path inside the app folder"));
|
|
}
|
|
if open == Some(true) {
|
|
if !policy::openable(&relp, real.is_dir()) {
|
|
return Err(Fail::host("only folders and the gallery page open from here"));
|
|
}
|
|
return app.opener().open_path(real.to_string_lossy(), None::<&str>).map_err(|e| Fail::host(e.to_string()));
|
|
}
|
|
app.opener().reveal_item_in_dir(&real).map_err(|e| Fail::host(e.to_string()))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn project_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> {
|
|
guard(&window)?;
|
|
let mut st = saved(&state)?;
|
|
let removed: Vec<String> = st.projects.iter().filter(|p| p.id == id).map(|p| p.path.clone()).collect();
|
|
st.projects.retain(|p| p.id != id);
|
|
st.overrides.links.retain(|l| l.project != id);
|
|
let (ig, key) = (format!("project:{id}"), format!("project:{id}"));
|
|
st.overrides.ignored.retain(|i| i != &ig);
|
|
st.overrides.app_studio.retain(|a| a.key != key);
|
|
let _ = state.cache.forget_project(&id);
|
|
persist(&state, &st)?;
|
|
publish_folders(&state, &st, &removed);
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn project_set_accounts(window: WebviewWindow, state: State<'_, AppState>, id: String, play: Option<String>, apple: Option<String>) -> Res<Project> {
|
|
guard(&window)?;
|
|
let mut st = saved(&state)?;
|
|
let kind_of = |acc: &Option<String>| acc.as_ref().map(|a| st.accounts.iter().find(|x| &x.id == a).map(|x| x.kind));
|
|
if let Some(k) = kind_of(&play) {
|
|
if k != Some(AccountKind::Play) {
|
|
return Err(Fail::host("not a Google Play account"));
|
|
}
|
|
}
|
|
if let Some(k) = kind_of(&apple) {
|
|
if k != Some(AccountKind::Apple) {
|
|
return Err(Fail::host("not an App Store Connect account"));
|
|
}
|
|
}
|
|
let p = st.projects.iter_mut().find(|p| p.id == id).ok_or_else(|| Fail::host("unknown project"))?;
|
|
p.play_account = play;
|
|
p.apple_account = apple;
|
|
let out = p.clone();
|
|
persist(&state, &st)?;
|
|
Ok(out)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn accounts_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<Account>> {
|
|
guard(&window)?;
|
|
sync_machine(&state)?;
|
|
Ok(saved(&state)?.accounts.clone())
|
|
}
|
|
|
|
fn check_studio(state: &AppState, studio: &Option<String>) -> Res<()> {
|
|
if let Some(s) = studio {
|
|
if !saved(state)?.studios.iter().any(|x| &x.id == s) {
|
|
return Err(Fail::host("unknown studio"));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// A new key goes into the machine's credential profiles (the profile that already has this key, else one named after
|
|
/// the label), so the CLI sees it too. The same key twice is one account.
|
|
fn add_account(state: &AppState, label: &str, secret: Secret, studio: Option<String>) -> Res<Account> {
|
|
let label = accounts::check_label(label).map_err(Fail::host)?;
|
|
check_studio(state, &studio)?;
|
|
let profile = state.profiles.place(&label, &secret);
|
|
state.profiles.save(&profile, &label, &secret).map_err(Fail::host)?;
|
|
let mut st = saved(state)?;
|
|
if let Some(a) = st.accounts.iter_mut().find(|a| a.kind == secret.kind() && a.profile.as_deref() == Some(profile.as_str())) {
|
|
a.label = label;
|
|
if studio.is_some() {
|
|
a.studio = studio;
|
|
}
|
|
let out = a.clone();
|
|
persist(state, &st)?;
|
|
return Ok(out);
|
|
}
|
|
let id = profiles::account_id(secret.kind(), &profile);
|
|
let id = if st.accounts.iter().any(|a| a.id == id) { store::new_id()? } else { id };
|
|
let acc = Account { id, label, kind: secret.kind(), created: store::now_iso(), studio, profile: Some(profile) };
|
|
st.accounts.push(acc.clone());
|
|
persist(state, &st)?;
|
|
Ok(acc)
|
|
}
|
|
|
|
async fn pick_file(window: &WebviewWindow, title: &'static str, filter: (&'static str, &'static [&'static str])) -> Res<Option<String>> {
|
|
let w = window.clone();
|
|
let picked = tauri::async_runtime::spawn_blocking(move || w.app_handle().dialog().file().set_title(title).add_filter(filter.0, filter.1).set_parent(&w).blocking_pick_file())
|
|
.await
|
|
.map_err(|e| Fail::host(e.to_string()))?;
|
|
let Some(p) = picked else { return Ok(None) };
|
|
let path = p.into_path().map_err(|e| Fail::host(e.to_string()))?;
|
|
let meta = std::fs::metadata(&path).map_err(|e| Fail::host(e.to_string()))?;
|
|
if meta.len() > 64 * 1024 {
|
|
return Err(Fail::host("file too large for a key"));
|
|
}
|
|
std::fs::read_to_string(&path).map(Some).map_err(|e| Fail::host(e.to_string()))
|
|
}
|
|
|
|
/// Google Play: pick the service account JSON key in a native dialog; it goes into a credential profile (0600).
|
|
#[tauri::command]
|
|
pub async fn account_add_play(window: WebviewWindow, state: State<'_, AppState>, label: String, studio: Option<String>) -> Res<Option<Account>> {
|
|
guard(&window)?;
|
|
accounts::check_label(&label).map_err(Fail::host)?;
|
|
check_studio(&state, &studio)?;
|
|
let Some(text) = pick_file(&window, "Google Play service account key (JSON)", ("JSON key", &["json"])).await? else { return Ok(None) };
|
|
let sa = accounts::check_service_account(&text).map_err(Fail::host)?;
|
|
add_account(&state, &label, Secret::Play { service_account: sa }, studio).map(Some)
|
|
}
|
|
|
|
/// App Store Connect: key id + issuer id typed in the app, the .p8 picked in a native dialog; all into a credential
|
|
/// profile (0600).
|
|
#[tauri::command]
|
|
pub async fn account_add_apple(window: WebviewWindow, state: State<'_, AppState>, label: String, key_id: String, issuer_id: String, studio: Option<String>) -> Res<Option<Account>> {
|
|
guard(&window)?;
|
|
accounts::check_label(&label).map_err(Fail::host)?;
|
|
check_studio(&state, &studio)?;
|
|
let key_id = accounts::check_key_id(&key_id).map_err(Fail::host)?;
|
|
let issuer_id = accounts::check_issuer_id(&issuer_id).map_err(Fail::host)?;
|
|
let Some(text) = pick_file(&window, "App Store Connect API key (.p8)", ("API key", &["p8"])).await? else { return Ok(None) };
|
|
let p8 = accounts::check_p8(&text).map_err(Fail::host)?;
|
|
add_account(&state, &label, Secret::Apple { key_id, issuer_id, p8 }, studio).map(Some)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn account_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> {
|
|
guard(&window)?;
|
|
let mut st = saved(&state)?;
|
|
let acc = st.accounts.iter().find(|a| a.id == id).cloned().ok_or_else(|| Fail::host("unknown account"))?;
|
|
// the key leaves the shared profile (moved to credentials/.removed/, never deleted outright), or the keychain
|
|
match &acc.profile {
|
|
Some(p) => {
|
|
if state.profiles.names().contains(p) && state.profiles.kinds(p).contains(&acc.kind) {
|
|
let stamp = store::now_iso().replace(['-', ':'], "");
|
|
state.profiles.remove(p, acc.kind, &stamp).map_err(Fail::host)?;
|
|
}
|
|
}
|
|
None => state.legacy.delete(&id).map_err(Fail::host)?,
|
|
}
|
|
st.accounts.retain(|a| a.id != id);
|
|
let ignored = format!("account:{id}");
|
|
st.overrides.ignored.retain(|i| i != &ignored);
|
|
let _ = state.cache.forget_project(&format!("acct:{id}"));
|
|
for p in st.projects.iter_mut() {
|
|
if p.play_account.as_deref() == Some(id.as_str()) {
|
|
p.play_account = None;
|
|
}
|
|
if p.apple_account.as_deref() == Some(id.as_str()) {
|
|
p.apple_account = None;
|
|
}
|
|
}
|
|
persist(&state, &st)
|
|
}
|
|
|
|
/// Store-only apps one live portfolio read takes at most.
|
|
const MAX_STORE_TARGETS: usize = 300;
|
|
|
|
/// `portfolio.status` over every project. Offline unless `live`; live reads every app with its own accounts, several
|
|
/// at once, and sends one progress line per finished app (job progress n / total). Each live app result is cached;
|
|
/// the whole live result too (project "*"), so the portfolio shows the last read when offline.
|
|
#[tauri::command]
|
|
pub async fn portfolio_status(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, live: Option<bool>, stores: Option<Vec<String>>, job: Option<String>) -> Res<Value> {
|
|
guard(&window)?;
|
|
let job = job_of(job)?;
|
|
let (projects, parallel) = {
|
|
let st = saved(&state)?;
|
|
let ignored: Vec<&str> = st.overrides.ignored.iter().filter_map(|i| i.strip_prefix("project:")).collect();
|
|
// folders that are gone are skipped (the window shows them as "Folder not found")
|
|
let projects = st.projects.iter().filter(|p| !ignored.contains(&p.id.as_str()) && Path::new(&p.path).is_dir()).cloned().collect::<Vec<_>>();
|
|
(projects, st.settings.refresh.parallel.clamp(1, 8) as usize)
|
|
};
|
|
// store-only apps (live reads only): validated targets with their accounts' credentials
|
|
let mut targets = Vec::new();
|
|
if live == Some(true) {
|
|
for key in stores.unwrap_or_default().into_iter().take(MAX_STORE_TARGETS) {
|
|
let t = StoreTarget::parse(&key).map_err(Fail::host)?;
|
|
let creds = target_params(&state, &t)?;
|
|
targets.push((key, t, creds));
|
|
}
|
|
}
|
|
if projects.is_empty() && targets.is_empty() {
|
|
return Ok(json!({ "apps": [], "at": store::now_iso() }));
|
|
}
|
|
let r = state.sidecar.get(&app).await?;
|
|
if live != Some(true) {
|
|
let paths: Vec<&str> = projects.iter().map(|p| p.path.as_str()).collect();
|
|
return r.call_job("portfolio.status", json!({ "projects": paths }), job.as_deref()).await;
|
|
}
|
|
let total = projects.len() + targets.len();
|
|
let sem = Arc::new(tokio::sync::Semaphore::new(parallel));
|
|
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
let mut handles = Vec::new();
|
|
for p in &projects {
|
|
let (_, creds) = project_params(&state, &p.id)?;
|
|
let (r, sem, done, app, job, path, id) = (r.clone(), sem.clone(), done.clone(), app.clone(), job.clone(), p.path.clone(), p.id.clone());
|
|
handles.push(tauri::async_runtime::spawn(async move {
|
|
let _permit = sem.acquire_owned().await.ok();
|
|
let sub = job.as_ref().map(|j| format!("{j}:p"));
|
|
let res = r.call_job("portfolio.status", json!({ "projects": [path], "live": true, "credentials": creds }), sub.as_deref()).await;
|
|
let n = done.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
|
|
let _ = app.emit_to(policy::MAIN, "sidecar-progress", Progress { params: json!({ "requestId": 0, "n": n, "total": total, "project": path }), job: job.clone() });
|
|
(id, res)
|
|
}));
|
|
}
|
|
for (key, t, creds) in targets {
|
|
let (r, sem, done, app, job, id) = (r.clone(), sem.clone(), done.clone(), app.clone(), job.clone(), key.clone());
|
|
let entry = json!({ "key": key, "virtual": t.virtual_project() });
|
|
handles.push(tauri::async_runtime::spawn(async move {
|
|
let _permit = sem.acquire_owned().await.ok();
|
|
let sub = job.as_ref().map(|j| format!("{j}:p"));
|
|
let res = r.call_job("portfolio.status", json!({ "projects": [entry], "live": true, "credentials": creds }), sub.as_deref()).await;
|
|
let n = done.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
|
|
let _ = app.emit_to(policy::MAIN, "sidecar-progress", Progress { params: json!({ "requestId": 0, "n": n, "total": total, "project": id }), job: job.clone() });
|
|
(id, res)
|
|
}));
|
|
}
|
|
let mut all = Vec::new();
|
|
let at = store::now_iso();
|
|
for h in handles {
|
|
let (id, res) = h.await.map_err(|e| Fail::host(e.to_string()))?;
|
|
let res = res?;
|
|
for a in res["apps"].as_array().cloned().unwrap_or_default() {
|
|
let _ = state.cache.put(&id, "portfolio.app", &json!({}), &a, &at);
|
|
all.push(a);
|
|
}
|
|
}
|
|
let out = json!({ "apps": all, "at": at });
|
|
let _ = state.cache.put("*", "portfolio.status", &json!({ "live": true }), &out, &at);
|
|
Ok(out)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn open_external(window: WebviewWindow, app: AppHandle, url: String) -> Res<()> {
|
|
guard(&window)?;
|
|
let u = url::Url::parse(&url).map_err(|_| Fail::host("not a URL"))?;
|
|
if !policy::external_allowed(&u) {
|
|
return Err(Fail::host("only the store consoles and storebound.dev open from here"));
|
|
}
|
|
app.opener().open_url(u.as_str(), None::<&str>).map_err(|e| Fail::host(e.to_string()))
|
|
}
|
|
|
|
/// What the Dependencies screen can download on this platform, and what is installed.
|
|
#[tauri::command]
|
|
pub fn tools_info(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<downloads::DownloadInfo>> {
|
|
guard(&window)?;
|
|
downloads::info(&state.data_dir).map_err(Fail::host)
|
|
}
|
|
|
|
/// Downloads, verifies and installs ffmpeg or Chrome for Testing into the app data folder (progress: `tool-download`
|
|
/// events with the job id; job_cancel stops it). The sidecar restarts afterwards so it sees the new tool.
|
|
#[tauri::command]
|
|
pub async fn tools_install(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, tool: String, job: String) -> Res<String> {
|
|
guard(&window)?;
|
|
if !valid_job(&job) || !matches!(tool.as_str(), "ffmpeg" | "chrome") {
|
|
return Err(Fail::host("bad tool or job"));
|
|
}
|
|
let flag = state.downloads.start(&job).map_err(Fail::host)?;
|
|
let handle = app.clone();
|
|
let res = downloads::install(&state.data_dir, &tool, &job, flag, move |e| {
|
|
let _ = handle.emit_to(policy::MAIN, "tool-download", e);
|
|
})
|
|
.await;
|
|
state.downloads.finish(&job);
|
|
if res.is_ok() {
|
|
state.sidecar.shutdown().await;
|
|
}
|
|
res.map_err(Fail::host)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn tools_uninstall(window: WebviewWindow, state: State<'_, AppState>, tool: String) -> Res<()> {
|
|
guard(&window)?;
|
|
downloads::uninstall(&state.data_dir, &tool).map_err(Fail::host)?;
|
|
state.sidecar.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
// ---- D3: studios, store accounts' apps, pairing overrides, ignore list, settings ----
|
|
|
|
#[derive(Serialize)]
|
|
pub struct Workspace {
|
|
studios: Vec<Studio>,
|
|
overrides: Overrides,
|
|
settings: Settings,
|
|
}
|
|
|
|
/// Studios, the owner's pairing / link / ignore overrides and the app settings in one read.
|
|
#[tauri::command]
|
|
pub fn workspace_get(window: WebviewWindow, state: State<'_, AppState>) -> Res<Workspace> {
|
|
guard(&window)?;
|
|
let st = saved(&state)?;
|
|
Ok(Workspace { studios: st.studios.clone(), overrides: st.overrides.clone(), settings: st.settings.clone() })
|
|
}
|
|
|
|
/// Creates (no id) or renames / recolours a studio.
|
|
#[tauri::command]
|
|
pub fn studio_save(window: WebviewWindow, state: State<'_, AppState>, id: Option<String>, name: String, color: String) -> Res<Studio> {
|
|
guard(&window)?;
|
|
let name = workspace::check_studio_name(&name).map_err(Fail::host)?;
|
|
let color = workspace::check_color(&color).map_err(Fail::host)?;
|
|
let mut st = saved(&state)?;
|
|
let out = match id {
|
|
Some(id) => {
|
|
let s = st.studios.iter_mut().find(|s| s.id == id).ok_or_else(|| Fail::host("unknown studio"))?;
|
|
s.name = name;
|
|
s.color = color;
|
|
s.clone()
|
|
}
|
|
None => {
|
|
if st.studios.len() >= 100 {
|
|
return Err(Fail::host("at most 100 studios"));
|
|
}
|
|
let s = Studio { id: store::new_id()?, name, color, logo: None, created: store::now_iso() };
|
|
st.studios.push(s.clone());
|
|
s
|
|
}
|
|
};
|
|
persist(&state, &st)?;
|
|
Ok(out)
|
|
}
|
|
|
|
/// Removes a studio; its accounts and apps stay, without a studio.
|
|
#[tauri::command]
|
|
pub fn studio_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> {
|
|
guard(&window)?;
|
|
let mut st = saved(&state)?;
|
|
st.studios.retain(|s| s.id != id);
|
|
for a in st.accounts.iter_mut() {
|
|
if a.studio.as_deref() == Some(id.as_str()) {
|
|
a.studio = None;
|
|
}
|
|
}
|
|
st.overrides.app_studio.retain(|a| a.studio != id);
|
|
persist(&state, &st)
|
|
}
|
|
|
|
/// Studio logo: a small PNG / JPEG / WebP picked in a native dialog (kept as a data URL), or `clear`.
|
|
#[tauri::command]
|
|
pub async fn studio_logo(window: WebviewWindow, state: State<'_, AppState>, id: String, clear: bool) -> Res<Option<Studio>> {
|
|
guard(&window)?;
|
|
if !saved(&state)?.studios.iter().any(|s| s.id == id) {
|
|
return Err(Fail::host("unknown studio"));
|
|
}
|
|
let logo = if clear {
|
|
None
|
|
} else {
|
|
let w = window.clone();
|
|
let picked = tauri::async_runtime::spawn_blocking(move || w.app_handle().dialog().file().set_title("Studio logo (PNG, JPEG or WebP)").add_filter("Image", &["png", "jpg", "jpeg", "webp"]).set_parent(&w).blocking_pick_file())
|
|
.await
|
|
.map_err(|e| Fail::host(e.to_string()))?;
|
|
let Some(p) = picked else { return Ok(None) };
|
|
let path = p.into_path().map_err(|e| Fail::host(e.to_string()))?;
|
|
if std::fs::metadata(&path).map_err(|e| Fail::host(e.to_string()))?.len() > workspace::LOGO_MAX as u64 {
|
|
return Err(Fail::host("logo: at most 256 KB"));
|
|
}
|
|
Some(workspace::logo_data_url(&std::fs::read(&path).map_err(|e| Fail::host(e.to_string()))?).map_err(Fail::host)?)
|
|
};
|
|
let mut st = saved(&state)?;
|
|
let s = st.studios.iter_mut().find(|s| s.id == id).ok_or_else(|| Fail::host("unknown studio"))?;
|
|
s.logo = logo;
|
|
let out = s.clone();
|
|
persist(&state, &st)?;
|
|
Ok(Some(out))
|
|
}
|
|
|
|
/// Renames an account or moves it to another studio (the key in its credential profile stays as it is).
|
|
#[tauri::command]
|
|
pub fn account_update(window: WebviewWindow, state: State<'_, AppState>, id: String, label: String, studio: Option<String>) -> Res<Account> {
|
|
guard(&window)?;
|
|
let label = accounts::check_label(&label).map_err(Fail::host)?;
|
|
check_studio(&state, &studio)?;
|
|
let mut st = saved(&state)?;
|
|
let a = st.accounts.iter_mut().find(|a| a.id == id).ok_or_else(|| Fail::host("unknown account"))?;
|
|
a.label = label;
|
|
a.studio = studio;
|
|
let out = a.clone();
|
|
persist(&state, &st)?;
|
|
Ok(out)
|
|
}
|
|
|
|
/// Does the account's key work, and can it see apps (sidecar `account.check`). Not cached: it is a live answer.
|
|
#[tauri::command]
|
|
pub async fn account_check(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, id: String, job: Option<String>) -> Res<Value> {
|
|
guard(&window)?;
|
|
let job = job_of(job)?;
|
|
let creds = account_creds(&state, &id)?;
|
|
let r = state.sidecar.get(&app).await?;
|
|
r.call_job("account.check", json!({ "credentials": creds }), job.as_deref()).await
|
|
}
|
|
|
|
/// Reads every app the account can see from its store (sidecar `apps.list`, icons per Settings) and keeps the list
|
|
/// (cache project "acct:<id>").
|
|
#[tauri::command]
|
|
pub async fn store_apps_refresh(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, id: String, job: Option<String>) -> Res<Entry> {
|
|
guard(&window)?;
|
|
let job = job_of(job)?;
|
|
let creds = account_creds(&state, &id)?;
|
|
let icons = saved(&state)?.settings.refresh.icons;
|
|
let r = state.sidecar.get(&app).await?;
|
|
let value = r.call_job("apps.list", json!({ "credentials": creds, "args": { "icons": icons } }), job.as_deref()).await?;
|
|
let at = store::now_iso();
|
|
state.cache.put(&format!("acct:{id}"), "apps.list", &json!({}), &value, &at).map_err(Fail::host)?;
|
|
let st = saved(&state)?;
|
|
let value = match st.accounts.iter().find(|a| a.id == id) {
|
|
Some(a) => store_only_into(&st.accounts, a, Some(value.clone()), ®istry::read_store_apps(&state.registry), &at).unwrap_or(value),
|
|
None => value,
|
|
};
|
|
Ok(Entry { value, at })
|
|
}
|
|
|
|
/// One account's app list with the machine's store-only apps of its credential profile added (registry.rs).
|
|
fn store_only_into(all: &[Account], a: &Account, list: Option<Value>, apps: &[registry::StoreOnly], at: &str) -> Option<Value> {
|
|
let store = match a.kind { AccountKind::Apple => "apple", AccountKind::Play => "play" };
|
|
let only = all.iter().filter(|x| x.kind == a.kind).count() == 1;
|
|
registry::with_store_only(list, store, a.profile.as_deref(), only, apps, at)
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct AccountApps {
|
|
account: String,
|
|
entry: Option<Entry>,
|
|
}
|
|
|
|
/// The last app list read of every account (null for an account never read).
|
|
#[tauri::command]
|
|
pub fn store_apps_cached(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<AccountApps>> {
|
|
guard(&window)?;
|
|
let accounts = saved(&state)?.accounts.clone();
|
|
let store_only = registry::read_store_apps(&state.registry);
|
|
accounts
|
|
.iter()
|
|
.map(|a| {
|
|
let entry = state.cache.get(&format!("acct:{}", a.id), "apps.list", &json!({})).map_err(Fail::host)?;
|
|
// apps live on a store with no project on this machine (`storebound apps add --store … --app-id …`)
|
|
let at = entry.as_ref().map(|e| e.at.clone()).unwrap_or_else(store::now_iso);
|
|
let entry = match store_only_into(&accounts, a, entry.as_ref().map(|e| e.value.clone()), &store_only, &at) {
|
|
Some(value) => Some(Entry { value, at }),
|
|
None => entry,
|
|
};
|
|
Ok(AccountApps { account: a.id.clone(), entry })
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Replaces the pairing / link / studio / ignore overrides (every key is checked).
|
|
#[tauri::command]
|
|
pub fn overrides_set(window: WebviewWindow, state: State<'_, AppState>, overrides: Overrides) -> Res<Overrides> {
|
|
guard(&window)?;
|
|
workspace::check_overrides(&overrides).map_err(Fail::host)?;
|
|
let mut st = saved(&state)?;
|
|
st.overrides = overrides.clone();
|
|
persist(&state, &st)?;
|
|
Ok(overrides)
|
|
}
|
|
|
|
/// Saves the app settings; a shorter "keep saved reads" drops older reads right away.
|
|
#[tauri::command]
|
|
pub fn settings_set(window: WebviewWindow, state: State<'_, AppState>, settings: Settings) -> Res<Settings> {
|
|
guard(&window)?;
|
|
workspace::check_settings(&settings).map_err(Fail::host)?;
|
|
let mut st = saved(&state)?;
|
|
st.settings = settings.clone();
|
|
persist(&state, &st)?;
|
|
drop(st);
|
|
prune_cache(&state, settings.cache.keep_days);
|
|
Ok(settings)
|
|
}
|
|
|
|
pub fn prune_cache(state: &AppState, keep_days: u32) {
|
|
if keep_days == 0 {
|
|
return;
|
|
}
|
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
|
let cutoff = store::iso_from_unix(now.saturating_sub(u64::from(keep_days) * 86_400));
|
|
let _ = state.cache.prune_before(&cutoff);
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct CacheInfo {
|
|
rows: u64,
|
|
bytes: u64,
|
|
oldest: Option<String>,
|
|
/// cache.sqlite (+ its WAL) on disk.
|
|
file_bytes: u64,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn cache_info(window: WebviewWindow, state: State<'_, AppState>) -> Res<CacheInfo> {
|
|
guard(&window)?;
|
|
let (rows, bytes, oldest) = state.cache.info().map_err(Fail::host)?;
|
|
let file_bytes = ["", "-wal", "-shm"].iter().filter_map(|s| std::fs::metadata(state.data_dir.join(format!("{}{s}", crate::cache::FILE))).ok()).map(|m| m.len()).sum();
|
|
Ok(CacheInfo { rows, bytes, oldest, file_bytes })
|
|
}
|
|
|
|
/// Shows the app data folder (state, saved reads, downloaded tools; never keys) in the file manager.
|
|
#[tauri::command]
|
|
pub fn data_reveal(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>) -> Res<()> {
|
|
guard(&window)?;
|
|
let _ = std::fs::create_dir_all(&state.data_dir);
|
|
app.opener().reveal_item_in_dir(state.data_dir.join(store::FILE)).or_else(|_| app.opener().open_path(state.data_dir.to_string_lossy(), None::<&str>)).map_err(|e| Fail::host(e.to_string()))
|
|
}
|
|
|
|
/// A desktop notification (review state changed, a new 1-2 star review, a crash rate over the threshold). The window
|
|
/// decides when, from Settings > Notifications; text is plain and short.
|
|
#[tauri::command]
|
|
pub fn notify(window: WebviewWindow, app: AppHandle, title: String, body: String) -> Res<()> {
|
|
guard(&window)?;
|
|
use tauri_plugin_notification::NotificationExt;
|
|
let cut = |s: &str, n: usize| s.chars().filter(|c| !c.is_control()).take(n).collect::<String>();
|
|
app.notification().builder().title(cut(&title, 80)).body(cut(&body, 240)).show().map_err(|e| Fail::host(e.to_string()))
|
|
}
|
|
|
|
/// Forgets everything after a native confirmation: store keys (keychain), studios, accounts, app folders (the folders
|
|
/// themselves stay), overrides, settings and saved reads. Downloaded tools stay. True when it was reset.
|
|
#[tauri::command]
|
|
pub async fn app_reset(window: WebviewWindow, state: State<'_, AppState>) -> Res<bool> {
|
|
guard(&window)?;
|
|
let w = window.clone();
|
|
let yes = tauri::async_runtime::spawn_blocking(move || {
|
|
w.app_handle()
|
|
.dialog()
|
|
.message("Forgets studios, labels, pairing, the ignore list, settings and saved store reads of this app.\n\nThe store accounts in ~/.storebound/credentials and the app folder list in ~/.storebound/projects.json stay: the storebound CLI uses them too (remove an account in Accounts first to remove its key). Downloaded tools stay. This cannot be undone.")
|
|
.title("Reset storebound desktop?")
|
|
.kind(MessageDialogKind::Warning)
|
|
.buttons(MessageDialogButtons::OkCancelCustom("Reset".into(), "Cancel".into()))
|
|
.parent(&w)
|
|
.blocking_show()
|
|
})
|
|
.await
|
|
.map_err(|e| Fail::host(e.to_string()))?;
|
|
if !yes {
|
|
return Ok(false);
|
|
}
|
|
let mut st = saved(&state)?;
|
|
// keys of earlier versions still in the keychain go; the shared credential profiles stay (the CLI uses them)
|
|
for a in st.accounts.iter().filter(|a| a.profile.is_none()) {
|
|
state.legacy.delete(&a.id).map_err(Fail::host)?;
|
|
}
|
|
*st = Saved::default();
|
|
persist(&state, &st)?;
|
|
drop(st);
|
|
state.cache.clear().map_err(Fail::host)?;
|
|
sync_machine(&state)?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// "Reset saved state" of the error screen: after a native confirmation, state.json is moved aside as
|
|
/// `state.json.reset-<time>.bak` (nothing is deleted: store keys stay in the keychain, saved reads stay) and the app
|
|
/// starts with an empty list. The backup's path (~), or null when cancelled.
|
|
#[tauri::command]
|
|
pub async fn state_reset(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>) -> Res<Option<String>> {
|
|
guard(&window)?;
|
|
let w = window.clone();
|
|
let yes = tauri::async_runtime::spawn_blocking(move || {
|
|
w.app_handle()
|
|
.dialog()
|
|
.message("storebound forgets its studios, pairing and settings, and starts again from the machine's accounts and app folders.\n\nNothing is deleted: the saved state is kept as a backup file in the app data folder, store keys stay in ~/.storebound/credentials, and your app folders are not touched.")
|
|
.title("Reset saved state?")
|
|
.kind(MessageDialogKind::Warning)
|
|
.buttons(MessageDialogButtons::OkCancelCustom("Reset and keep a backup".into(), "Cancel".into()))
|
|
.parent(&w)
|
|
.blocking_show()
|
|
})
|
|
.await
|
|
.map_err(|e| Fail::host(e.to_string()))?;
|
|
if !yes {
|
|
return Ok(None);
|
|
}
|
|
let mut st = saved(&state)?;
|
|
let backup = store::reset(&state.data_dir).map_err(Fail::host)?;
|
|
*st = Saved::default();
|
|
persist(&state, &st)?;
|
|
let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string());
|
|
let shown = backup.map(|b| tilde(&b.to_string_lossy(), home.as_deref())).unwrap_or_default();
|
|
crate::logs::host("warn", &format!("saved state reset from the error screen; backup: {shown}"));
|
|
Ok(Some(shown))
|
|
}
|
|
|
|
/// Uncaught errors of the window (window.onerror, unhandled rejections, React error boundaries) into
|
|
/// `<app data>/logs/webview.log`. Sanitized there (home as ~, no keys, tokens or e-mail addresses), size-capped, and
|
|
/// limited to WEBVIEW_LOG_BURST entries a minute so a render loop cannot fill the disk.
|
|
#[tauri::command]
|
|
pub fn log_webview(window: WebviewWindow, level: String, message: String, detail: Option<String>) -> Res<()> {
|
|
guard(&window)?;
|
|
static BUDGET: Mutex<(u64, u32)> = Mutex::new((0, 0));
|
|
let minute = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() / 60).unwrap_or(0);
|
|
{
|
|
let mut b = BUDGET.lock().map_err(|_| Fail::host("log state poisoned"))?;
|
|
if b.0 != minute {
|
|
*b = (minute, 0);
|
|
}
|
|
b.1 += 1;
|
|
if b.1 > WEBVIEW_LOG_BURST {
|
|
return Ok(());
|
|
}
|
|
}
|
|
let text = match detail.filter(|d| !d.trim().is_empty()) {
|
|
Some(d) => format!("{message}\n{d}"),
|
|
None => message,
|
|
};
|
|
crate::logs::webview(&level, &text);
|
|
Ok(())
|
|
}
|
|
|
|
pub const WEBVIEW_LOG_BURST: u32 = 60;
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
// Updates (Settings > Updates): signed bundles from storebound.dev, installed only after the owner's click.
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct UpdateInfo {
|
|
pub current: String,
|
|
pub channel: String,
|
|
/// The newer version on the channel, if any.
|
|
pub available: Option<String>,
|
|
pub notes: Option<String>,
|
|
pub date: Option<String>,
|
|
}
|
|
|
|
async fn find_update(app: &AppHandle, channel: &str) -> Res<Option<tauri_plugin_updater::Update>> {
|
|
use tauri_plugin_updater::UpdaterExt;
|
|
let url = url::Url::parse(updates::endpoint(channel)).map_err(|e| Fail::host(e.to_string()))?;
|
|
if !updates::endpoint_allowed(&url) {
|
|
return Err(Fail::host("update endpoint not allowed"));
|
|
}
|
|
let updater = app
|
|
.updater_builder()
|
|
.endpoints(vec![url])
|
|
.map_err(|e| Fail::host(e.to_string()))?
|
|
.timeout(std::time::Duration::from_secs(30))
|
|
.build()
|
|
.map_err(|e| Fail::host(updates::error_text(&e.to_string())))?;
|
|
updater.check().await.map_err(|e| Fail::host(updates::error_text(&e.to_string())))
|
|
}
|
|
|
|
/// Asks storebound.dev whether the chosen channel has a newer version. Reads only; installs nothing.
|
|
#[tauri::command]
|
|
pub async fn update_check(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>) -> Res<UpdateInfo> {
|
|
guard(&window)?;
|
|
let channel = saved(&state)?.settings.channel.clone();
|
|
let found = find_update(&app, &channel).await?;
|
|
Ok(UpdateInfo {
|
|
current: env!("CARGO_PKG_VERSION").into(),
|
|
channel,
|
|
available: found.as_ref().map(|u| u.version.clone()),
|
|
notes: found.as_ref().and_then(|u| u.body.clone()),
|
|
date: found.as_ref().and_then(|u| u.date).and_then(|d| u64::try_from(d.unix_timestamp()).ok()).map(store::iso_from_unix),
|
|
})
|
|
}
|
|
|
|
/// Installs `version` (the one the window showed) after a native dialog: download, signature check against the key
|
|
/// in tauri.conf.json, install, restart. False = the owner said no. Progress goes to the window as `update-progress`.
|
|
#[tauri::command]
|
|
pub async fn update_install(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, version: String) -> Res<bool> {
|
|
guard(&window)?;
|
|
let channel = saved(&state)?.settings.channel.clone();
|
|
let Some(update) = find_update(&app, &channel).await? else {
|
|
return Err(Fail::host("no update available"));
|
|
};
|
|
if update.version != version {
|
|
return Err(Fail::host(format!("storebound.dev now offers {} instead of {version}: check again", update.version)));
|
|
}
|
|
let text = format!(
|
|
"storebound {version} is ready (this is {}).\n\nThe update comes from storebound.dev; its signature is checked against this app's key before anything is replaced. Then storebound restarts.",
|
|
env!("CARGO_PKG_VERSION")
|
|
);
|
|
if !confirm_dialog_with(&window, "Install the update?".into(), text, "Install and restart".into()).await? {
|
|
return Ok(false);
|
|
}
|
|
// the sidecar binary lives inside the app bundle that is about to be replaced
|
|
state.sidecar.shutdown().await;
|
|
let w = window.clone();
|
|
let mut received: u64 = 0;
|
|
update
|
|
.download_and_install(
|
|
move |n, total| {
|
|
received += n as u64;
|
|
let _ = w.emit("update-progress", json!({ "received": received, "total": total }));
|
|
},
|
|
|| {},
|
|
)
|
|
.await
|
|
.map_err(|e| Fail::host(updates::error_text(&e.to_string())))?;
|
|
app.restart();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
// storebound://open?path=<folder> (`storebound desktop` in a terminal): select that app folder, or add it after a
|
|
// native dialog. Not a command: lib.rs calls it for a deep link.
|
|
|
|
fn tell(app: &AppHandle, text: String) {
|
|
let mut d = app.dialog().message(text).title("storebound").kind(MessageDialogKind::Info);
|
|
if let Some(w) = app.get_webview_window(policy::MAIN) {
|
|
d = d.parent(&w);
|
|
}
|
|
d.show(|_| {});
|
|
}
|
|
|
|
pub async fn open_folder_link(app: AppHandle, dir: PathBuf) {
|
|
if let Err(e) = open_folder(&app, &dir).await {
|
|
tell(&app, e.message);
|
|
}
|
|
}
|
|
|
|
async fn open_folder(app: &AppHandle, dir: &Path) -> Res<()> {
|
|
let state = app.state::<AppState>();
|
|
let window = app.get_webview_window(policy::MAIN).ok_or_else(|| Fail::host("no main window"))?;
|
|
let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string());
|
|
let shown = tilde(&dir.to_string_lossy(), home.as_deref());
|
|
let dir = std::fs::canonicalize(dir).map_err(|_| Fail::host(format!("{shown} does not exist")))?;
|
|
if !dir.is_dir() {
|
|
return Err(Fail::host(format!("{shown} is not a folder")));
|
|
}
|
|
if !has_config(&dir) {
|
|
return Err(Fail::host(format!(
|
|
"{shown} has no storebound config.\n\nStart one with `storebound init` in that folder, or in storebound: App folders > Start an app folder."
|
|
)));
|
|
}
|
|
let path = dir.to_string_lossy().to_string();
|
|
// `storebound desktop` put the folder on the machine's list first: then it is selected without a question
|
|
sync_machine(&state)?;
|
|
let known = saved(&state)?.projects.iter().find(|p| p.path == path).map(|p| p.id.clone());
|
|
let id = match known {
|
|
Some(id) => id,
|
|
None => {
|
|
let text = format!("{}\n\nAdd this app folder to storebound? Nothing in the folder changes.", tilde(&path, home.as_deref()));
|
|
if !confirm_dialog_with(&window, "Add an app folder".into(), text, "Add folder".into()).await? {
|
|
return Ok(());
|
|
}
|
|
add_folder(app, &state, &dir).await?.id
|
|
}
|
|
};
|
|
let _ = app.emit_to(policy::MAIN, "deep-link-folder", id);
|
|
Ok(())
|
|
}
|
|
|
|
// ---- Fix with AI (src/ai_fix.rs): the owner's local Claude Code CLI on one scan finding ----
|
|
|
|
fn project_dir(state: &AppState, project_id: &str) -> Res<PathBuf> {
|
|
let p = saved(state)?.projects.iter().find(|p| p.id == project_id).cloned().ok_or_else(|| Fail::host("unknown project"))?;
|
|
Ok(PathBuf::from(p.path))
|
|
}
|
|
|
|
/// Whether claude is installed, the prompt (also what "Copy for AI" copies) and what is dirty in git.
|
|
#[tauri::command]
|
|
pub async fn ai_fix_info(window: WebviewWindow, state: State<'_, AppState>, project_id: String, finding: ai_fix::AiFinding) -> Res<Value> {
|
|
guard(&window)?;
|
|
let prompt = ai_fix::build_prompt(&finding).map_err(Fail::host)?;
|
|
let dir = project_dir(&state, &project_id)?;
|
|
let claude = ai_fix::find_claude();
|
|
let (git, dirty) = match tauri::async_runtime::spawn_blocking(move || ai_fix::status(&dir)).await.map_err(|e| Fail::host(e.to_string()))? {
|
|
Ok((_, d)) => (Ok(()), d),
|
|
Err(e) => (Err(e), Vec::new()),
|
|
};
|
|
Ok(json!({ "claude": claude.is_some(), "prompt": prompt, "git": git.is_ok(), "gitError": git.err(), "dirty": dirty }))
|
|
}
|
|
|
|
/// Runs claude on one finding in the app folder (clean git tree only), streams "ai-fix" lines, returns the run's changes.
|
|
#[tauri::command]
|
|
pub async fn ai_fix_run(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, project_id: String, finding: ai_fix::AiFinding, job: String) -> Res<Value> {
|
|
guard(&window)?;
|
|
if !valid_job(&job) {
|
|
return Err(Fail::host("bad job id"));
|
|
}
|
|
let prompt = ai_fix::build_prompt(&finding).map_err(Fail::host)?;
|
|
let dir = project_dir(&state, &project_id)?;
|
|
let claude = ai_fix::find_claude().ok_or_else(|| Fail::host("Claude Code (claude) was not found on PATH"))?;
|
|
let handle = app.clone();
|
|
let res = tauri::async_runtime::spawn_blocking(move || -> Result<Value, String> {
|
|
let (top, dirty) = ai_fix::status(&dir)?;
|
|
if !dirty.is_empty() {
|
|
return Ok(json!({ "refused": "dirty", "dirty": dirty }));
|
|
}
|
|
let cmd = ai_fix::claude_command(&claude, &dir, &prompt)?;
|
|
let emit_job = job.clone();
|
|
let out = ai_fix::run(cmd, &job, ai_fix::TIMEOUT, ai_fix::HEARTBEAT, |line| {
|
|
let _ = handle.emit_to(policy::MAIN, "ai-fix", json!({ "job": emit_job, "line": line }));
|
|
})?;
|
|
let (_, changes) = ai_fix::status(&dir)?;
|
|
let diff = ai_fix::diff(&top, &changes);
|
|
let run_id = job.clone();
|
|
if let Ok(mut m) = ai_fix::runs().lock() {
|
|
m.insert(run_id.clone(), (top, changes.clone()));
|
|
}
|
|
Ok(json!({ "runId": run_id, "outcome": out, "changes": changes, "diff": diff }))
|
|
})
|
|
.await
|
|
.map_err(|e| Fail::host(e.to_string()))?;
|
|
res.map_err(Fail::host)
|
|
}
|
|
|
|
/// Undo of one run: restores exactly the files that run changed (recorded by the host) and removes the ones it created.
|
|
#[tauri::command]
|
|
pub async fn ai_fix_undo(window: WebviewWindow, run_id: String) -> Res<Vec<String>> {
|
|
guard(&window)?;
|
|
let rec = ai_fix::runs().lock().ok().and_then(|mut m| m.remove(&run_id)).ok_or_else(|| Fail::host("no such AI fix run"))?;
|
|
tauri::async_runtime::spawn_blocking(move || ai_fix::undo(&rec.0, &rec.1)).await.map_err(|e| Fail::host(e.to_string()))?.map_err(Fail::host)
|
|
}
|