Marketplace: Tauri commands, store-owned fields and startup refresh

The 21 marketplace commands, registered and granted; marketplace fields
kept store-owned in update_settings/update_project; a background refresh
of every marketplace at app start.

- apply_marketplace_now emits marketplace-sync-finished per project (F4).
- Settings export carries marketplace account tokens in ExportedSecrets
  (account id -> token) and import restores them; imported accounts,
  marketplaces and global installs are validated with the commands' own
  rules before anything is written. The import preview discloses the
  marketplace count, token count and global hook installs, and warns on
  the latter (F10).
- refresh_pins and cache removal hold the repo lock (F11).
- ops::validate_host/validate_branch delegate to auth::valid_host and
  git::valid_branch (F13).
- A finished gh container login frees only its own cancel slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 09:41:59 -07:00
co-authored by Claude Opus 5.5
parent 7a55c11b31
commit f4153dce42
17 changed files with 2111 additions and 6 deletions
File diff suppressed because it is too large Load Diff
+1
View File
@@ -8,6 +8,7 @@ pub mod gateway_commands;
pub mod help_commands;
pub mod inspect_commands;
pub mod install_helper_commands;
pub mod marketplace_commands;
pub mod migration_commands;
pub mod notes_commands;
pub mod project_commands;
@@ -1148,6 +1148,9 @@ fn restore_store_owned_fields(project: &mut Project, stored: &Project) {
project.browser_view_enabled = stored.browser_view_enabled;
project.auth_bridge_enabled = stored.auth_bridge_enabled;
project.created_at = stored.created_at.clone();
// Owned by the marketplace commands; a Config-tab save carries a stale copy.
project.marketplace_installs = stored.marketplace_installs.clone();
project.marketplace_disabled = stored.marketplace_disabled.clone();
}
#[tauri::command]
@@ -2300,4 +2303,28 @@ mod tests {
assert_eq!(payload.status, ProjectStatus::Running);
assert_eq!(payload.created_at, stored.created_at);
}
/// The marketplace commands own a project's installs and opt-outs; the
/// Config tab's next unrelated save carries a stale copy of both.
#[test]
fn a_stale_save_cannot_undo_a_marketplace_install() {
use crate::models::marketplace::{ItemKind, MarketplaceInstall, MarketplaceItemRef};
let (mut stored, mut payload) = stored_and_stale_payload();
stored.marketplace_installs = vec![MarketplaceInstall {
marketplace_id: "m1".into(),
kind: ItemKind::Agent,
key: "code-reviewer".into(),
commit: "a".repeat(40),
}];
stored.marketplace_disabled = vec![MarketplaceItemRef {
marketplace_id: "m1".into(),
kind: ItemKind::Hook,
key: "h".into(),
}];
restore_store_owned_fields(&mut payload, &stored);
assert_eq!(payload.marketplace_installs, stored.marketplace_installs);
assert_eq!(payload.marketplace_disabled, stored.marketplace_disabled);
}
}
@@ -67,14 +67,26 @@ pub fn validate_settings_update(
Ok(())
}
/// Marketplace state is written only by the marketplace commands
/// (`commands/marketplace_commands.rs`), each of which returns fresh settings.
/// Every other settings save posts the frontend's copy back whole, and that
/// copy can predate an install made a moment ago, so what is stored wins.
/// `apply_settings_import` is the one caller that replaces it, explicitly.
pub(crate) fn restore_marketplace_fields(incoming: &mut AppSettings, stored: &AppSettings) {
incoming.marketplace_accounts = stored.marketplace_accounts.clone();
incoming.marketplaces = stored.marketplaces.clone();
incoming.global_marketplace_installs = stored.global_marketplace_installs.clone();
}
#[tauri::command]
pub async fn update_settings(
settings: AppSettings,
mut settings: AppSettings,
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
let before = state.settings_store.get();
validate_settings_update(&before, &settings)?;
restore_marketplace_fields(&mut settings, &before);
let saved = state.settings_store.update(settings)?;
@@ -430,4 +442,28 @@ mod tests {
});
assert_eq!(gateway_action(&before, &half_typed), GatewayAction::None);
}
#[test]
fn a_stale_settings_save_cannot_overwrite_marketplace_state() {
use crate::models::marketplace::Marketplace;
let mut stored = AppSettings::default();
stored.marketplaces.push(Marketplace {
id: "m1".into(),
name: "Team".into(),
url: "https://example.invalid/r.git".into(),
branch: None,
account_id: None,
});
// The frontend's copy predates the marketplace being added.
let mut incoming = AppSettings::default();
incoming.auto_check_updates = false;
restore_marketplace_fields(&mut incoming, &stored);
assert_eq!(incoming.marketplaces, stored.marketplaces);
assert!(
!incoming.auto_check_updates,
"the edit the save was for still applies"
);
}
}
@@ -41,6 +41,9 @@ use tauri::State;
use tauri_plugin_dialog::DialogExt;
use zeroize::Zeroizing;
use std::collections::BTreeMap;
use crate::models::marketplace::{AccountMethod, MarketplaceAccount};
use crate::models::{
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
@@ -131,11 +134,56 @@ fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSec
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
web_terminal_access_token,
marketplace_account_tokens: exported_marketplace_tokens(
&settings.marketplace_accounts,
secure::get_marketplace_token,
),
};
(settings, secrets)
}
/// The stored token of every marketplace account that has one, by account
/// id. A `GhHost` account stores none (its token is asked of the host's `gh`
/// each time), so it is not read. A missing or unreadable token is left out,
/// like the other keychain secrets above.
fn exported_marketplace_tokens(
accounts: &[MarketplaceAccount],
get: impl Fn(&str) -> Result<Option<String>, String>,
) -> BTreeMap<String, String> {
accounts
.iter()
.filter(|a| a.method != AccountMethod::GhHost)
.filter_map(|a| {
let token = non_blank(get(&a.id).unwrap_or_default())?;
Some((a.id.clone(), token))
})
.collect()
}
/// Write each imported marketplace token to the keychain, returning a
/// warning (never containing the token) for each one that could not be.
fn restore_marketplace_tokens(
tokens: &BTreeMap<String, String>,
mut store: impl FnMut(&str, &str) -> Result<(), String>,
) -> Vec<String> {
let mut warnings = Vec::new();
for (account_id, token) in tokens {
if let Err(e) = store(account_id, token) {
log::warn!(
"Settings import: could not restore the token of marketplace account {}: {}",
account_id,
e
);
warnings.push(format!(
"Could not restore a marketplace account's token ({}); sign that account in again.",
e
));
}
}
warnings
}
/// Export the current global settings and secrets to a password-encrypted
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
/// deliberately distinguishable from one so the frontend shows nothing
@@ -320,6 +368,13 @@ pub async fn apply_settings_import(
.or_else(|| current.web_terminal.access_token.clone());
crate::commands::settings_commands::validate_settings_update(&current, &settings)?;
// The marketplace half, with the commands' own rules and normalisation,
// also before anything is written (pre-flight F10).
let marketplace_tokens = payload.secrets.marketplace_account_tokens;
crate::commands::marketplace_commands::validate_imported_marketplace_state(
&mut settings,
&marketplace_tokens,
)?;
let mut secret_restore_warnings = Vec::new();
let mut gateway_secret_changed = false;
@@ -363,8 +418,29 @@ pub async fn apply_settings_import(
}
}
secret_restore_warnings.extend(restore_marketplace_tokens(
&marketplace_tokens,
secure::store_marketplace_token,
));
let imported_marketplace = (
settings.marketplace_accounts.clone(),
settings.marketplaces.clone(),
settings.global_marketplace_installs.clone(),
);
let saved =
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
// `update_settings` keeps marketplace state store-owned. An import is the
// one caller entitled to replace it wholesale.
let saved = {
let mut s = saved;
(
s.marketplace_accounts,
s.marketplaces,
s.global_marketplace_installs,
) = imported_marketplace;
state.settings_store.update(s)?
};
// `reconcile_gateway` (inside `update_settings`) only reacts to a changed
// *shape* — port, provider, base URL, models — because that's what's
@@ -651,4 +727,97 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
fn account(id: &str, method: AccountMethod) -> MarketplaceAccount {
MarketplaceAccount {
id: id.to_string(),
label: format!("Account {id}"),
host: "github.com".to_string(),
method,
username: None,
}
}
#[test]
fn export_carries_stored_tokens_of_token_and_container_accounts_only() {
let accounts = vec![
account("a-token", AccountMethod::Token),
account("a-container", AccountMethod::GhContainer),
account("a-host", AccountMethod::GhHost),
account("a-missing", AccountMethod::Token),
account("a-broken", AccountMethod::Token),
];
let tokens = exported_marketplace_tokens(&accounts, |id| match id {
"a-token" => Ok(Some("test-token-not-real-1".to_string())),
"a-container" => Ok(Some("test-token-not-real-2".to_string())),
"a-host" => panic!("a gh-host account stores no token, so none is read"),
"a-missing" => Ok(None),
_ => Err("keychain locked".to_string()),
});
assert_eq!(
tokens,
BTreeMap::from([
("a-container".to_string(), "test-token-not-real-2".to_string()),
("a-token".to_string(), "test-token-not-real-1".to_string()),
])
);
}
#[test]
fn marketplace_tokens_round_trip_through_an_export_and_validate_on_import() {
use crate::models::marketplace::Marketplace;
let id = "0f8fad5b-d9cb-469f-a165-70867728950e";
let mut payload = sample_payload(SETTINGS_EXPORT_FORMAT_VERSION);
payload
.settings
.marketplace_accounts
.push(account(id, AccountMethod::Token));
payload.settings.marketplaces.push(Marketplace {
id: "7c9e6679-7425-40de-944b-e07fc1f90ae7".into(),
name: "Team".into(),
url: "https://github.com/org/repo.git".into(),
branch: None,
account_id: Some(id.into()),
});
payload.secrets.marketplace_account_tokens =
BTreeMap::from([(id.to_string(), "test-token-not-real".to_string())]);
let dir = temp_dir("marketplace-round-trip");
let path = write_export(&dir, "x.triplec", &payload, "password123");
let mut back = read_and_decrypt(&path, "password123").unwrap();
assert_eq!(
back.secrets.marketplace_account_tokens,
payload.secrets.marketplace_account_tokens
);
assert_eq!(back.settings.marketplaces, payload.settings.marketplaces);
crate::commands::marketplace_commands::validate_imported_marketplace_state(
&mut back.settings,
&back.secrets.marketplace_account_tokens,
)
.unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_marketplace_token_that_fails_to_restore_is_reported_without_its_value() {
let tokens = BTreeMap::from([
("a1".to_string(), "test-token-not-real-1".to_string()),
("a2".to_string(), "test-token-not-real-2".to_string()),
]);
let mut stored = Vec::new();
let warnings = restore_marketplace_tokens(&tokens, |id, token| {
if id == "a2" {
return Err("keychain locked".to_string());
}
stored.push((id.to_string(), token.to_string()));
Ok(())
});
assert_eq!(
stored,
vec![("a1".to_string(), "test-token-not-real-1".to_string())]
);
assert_eq!(warnings.len(), 1);
assert!(!warnings[0].contains("test-token-not-real"));
}
}
+37 -1
View File
@@ -232,7 +232,6 @@ pub fn run() {
.unwrap_or_else(|| std::env::temp_dir().join("triple-c")),
));
let marketplace_setup = marketplace.clone();
let _ = &marketplace_setup;
// Clone Arcs for the setup closure (web terminal auto-start)
let projects_store_setup = projects_store.clone();
@@ -308,6 +307,21 @@ pub fn run() {
.await;
});
// Marketplaces: refresh each once at startup, in the background.
// Failures are logged, not toasted — the Marketplace tab shows them.
{
let settings = settings_store_setup.get();
let marketplace = marketplace_setup.clone();
tauri::async_runtime::spawn(async move {
for m in &settings.marketplaces {
let snap = crate::marketplace::refresh_marketplace(&marketplace, &settings, &m.id).await;
if let Some(e) = snap.fetch_error {
log::warn!("Marketplace \"{}\" could not be refreshed at startup: {}", m.name, e);
}
}
});
}
// Auto-start web terminal server if enabled in settings
let settings = settings_store_setup.get();
if settings.web_terminal.enabled {
@@ -529,6 +543,28 @@ pub fn run() {
commands::auth_token_commands::has_claude_token,
commands::auth_token_commands::clear_claude_token,
commands::auth_token_commands::sweep_claude_token_snapshots,
// Marketplace
commands::marketplace_commands::list_marketplace_snapshots,
commands::marketplace_commands::refresh_marketplaces,
commands::marketplace_commands::add_marketplace,
commands::marketplace_commands::update_marketplace,
commands::marketplace_commands::remove_marketplace,
commands::marketplace_commands::install_marketplace_item,
commands::marketplace_commands::uninstall_marketplace_item,
commands::marketplace_commands::set_global_item_disabled,
commands::marketplace_commands::forget_marketplace_installs,
commands::marketplace_commands::list_marketplace_updates,
commands::marketplace_commands::marketplace_item_diff,
commands::marketplace_commands::update_marketplace_item,
commands::marketplace_commands::apply_marketplace_now,
commands::marketplace_commands::get_marketplace_sync_report,
commands::marketplace_commands::add_marketplace_token_account,
commands::marketplace_commands::add_marketplace_gh_host_account,
commands::marketplace_commands::start_marketplace_gh_container_login,
commands::marketplace_commands::cancel_marketplace_gh_login,
commands::marketplace_commands::test_marketplace_account,
commands::marketplace_commands::remove_marketplace_account,
commands::marketplace_commands::marketplace_gh_host_available,
// Settings
commands::settings_commands::get_settings,
commands::settings_commands::update_settings,
+34
View File
@@ -167,6 +167,18 @@ impl MarketplaceManager {
}
}
/// Free the slot after a login ends, but only if it still holds that
/// login's sender (its receiver is gone once the login returns). A cancel
/// may have emptied the slot and a newer login claimed it meanwhile; a
/// plain `set_gh_login_cancel(None)` would drop that login's sender,
/// which it reads as a cancel.
pub async fn release_gh_login(&self) {
let mut slot = self.gh_login_cancel.lock().await;
if slot.as_ref().is_some_and(|tx| tx.is_closed()) {
*slot = None;
}
}
pub async fn cancel_gh_login(&self) {
if let Some(tx) = self.gh_login_cancel.lock().await.take() {
let _ = tx.send(());
@@ -664,6 +676,28 @@ mod tests {
);
}
#[tokio::test]
async fn releasing_a_finished_login_never_frees_a_newer_ones_slot() {
let mgr = MarketplaceManager::new(std::env::temp_dir());
// Login A is cancelled, and login B claims the slot before A returns.
let (tx_a, rx_a) = tokio::sync::oneshot::channel::<()>();
assert!(mgr.set_gh_login_cancel(Some(tx_a)).await);
mgr.cancel_gh_login().await;
let (tx_b, mut rx_b) = tokio::sync::oneshot::channel::<()>();
assert!(mgr.set_gh_login_cancel(Some(tx_b)).await);
drop(rx_a); // A returns.
mgr.release_gh_login().await;
assert!(
matches!(rx_b.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty)),
"B's sender must still be held, not dropped"
);
// B returns: its slot is freed.
drop(rx_b);
mgr.release_gh_login().await;
let (tx_c, _rx_c) = tokio::sync::oneshot::channel::<()>();
assert!(mgr.set_gh_login_cancel(Some(tx_c)).await);
}
#[test]
fn a_project_that_never_had_items_is_not_synced() {
let data = tempfile::tempdir().unwrap();
@@ -25,6 +25,8 @@
//! "only overwrite what the import actually has" treatment as the other
//! three secrets.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::{AppSettings, ImageSource};
@@ -56,6 +58,12 @@ pub struct ExportedSecrets {
/// export wholesale.
#[serde(default)]
pub web_terminal_access_token: Option<String>,
/// Marketplace account tokens (`Token` and `GhContainer` accounts; a
/// `GhHost` account stores none), keyed by account id. They live in the
/// keychain, not in `AppSettings::marketplace_accounts`, so they travel
/// here or an imported account could never fetch.
#[serde(default)]
pub marketplace_account_tokens: BTreeMap<String, String>,
}
impl ExportedSecrets {
@@ -65,6 +73,7 @@ impl ExportedSecrets {
&& blank(&self.gateway_api_key)
&& blank(&self.gateway_master_key)
&& blank(&self.web_terminal_access_token)
&& self.marketplace_account_tokens.values().all(|v| v.trim().is_empty())
}
}
@@ -147,6 +156,17 @@ pub struct SettingsImportPreview {
pub image_source: ImageSource,
#[serde(default)]
pub custom_image_name: Option<String>,
/// Marketplaces the import configures.
#[serde(default)]
pub marketplace_count: usize,
/// Hooks the import installs for every project. A hook runs commands in
/// each project container, and an imported install skips the confirm
/// step an install from the Marketplace tab shows, so the preview warns.
#[serde(default)]
pub global_hook_install_count: usize,
/// Non-blank marketplace account tokens the import restores.
#[serde(default)]
pub marketplace_account_token_count: usize,
}
/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
@@ -197,6 +217,19 @@ impl SettingsImportPreview {
gateway_api_base: sanitized_non_blank(&payload.settings.gateway.api_base),
image_source: payload.settings.image_source.clone(),
custom_image_name: sanitized_non_blank(&payload.settings.custom_image_name),
marketplace_count: payload.settings.marketplaces.len(),
global_hook_install_count: payload
.settings
.global_marketplace_installs
.iter()
.filter(|i| i.kind == crate::models::marketplace::ItemKind::Hook)
.count(),
marketplace_account_token_count: payload
.secrets
.marketplace_account_tokens
.values()
.filter(|v| !v.trim().is_empty())
.count(),
}
}
}
@@ -236,6 +269,7 @@ mod tests {
gateway_api_key: Some("sk-another-secret".to_string()),
gateway_master_key: Some("sk-triple-c-yet-another".to_string()),
web_terminal_access_token: Some("wt-super-secret-token".to_string()),
..Default::default()
});
let preview = SettingsImportPreview::from_payload(&payload);
let serialized = serde_json::to_string(&preview).unwrap();
@@ -260,6 +294,7 @@ mod tests {
gateway_api_key: None,
gateway_master_key: None,
web_terminal_access_token: Some(" ".to_string()),
..Default::default()
});
let preview = SettingsImportPreview::from_payload(&payload);
assert!(!preview.has_claude_oauth_token);
@@ -363,4 +398,52 @@ mod tests {
shown.chars().count()
);
}
#[test]
fn marketplaces_global_hooks_and_account_tokens_are_disclosed_without_the_tokens() {
use crate::models::marketplace::{ItemKind, Marketplace, MarketplaceInstall};
let mut payload = payload_with(ExportedSecrets {
marketplace_account_tokens: std::collections::BTreeMap::from([
("a1".to_string(), "test-token-not-real-1".to_string()),
("a2".to_string(), " ".to_string()),
]),
..Default::default()
});
payload.settings.marketplaces.push(Marketplace {
id: "m1".into(),
name: "Team".into(),
url: "https://example.invalid/r.git".into(),
branch: None,
account_id: None,
});
let install = |kind, key: &str| MarketplaceInstall {
marketplace_id: "m1".into(),
kind,
key: key.into(),
commit: "a".repeat(40),
};
payload.settings.global_marketplace_installs = vec![
install(ItemKind::Hook, "fmt"),
install(ItemKind::Agent, "rev"),
install(ItemKind::Hook, "lint"),
];
let preview = SettingsImportPreview::from_payload(&payload);
assert_eq!(preview.marketplace_count, 1);
assert_eq!(preview.global_hook_install_count, 2);
assert_eq!(preview.marketplace_account_token_count, 1, "a blank token is absent");
assert!(!serde_json::to_string(&preview).unwrap().contains("test-token-not-real"));
}
#[test]
fn a_bundle_holding_only_a_marketplace_token_is_not_empty() {
let secrets = ExportedSecrets {
marketplace_account_tokens: std::collections::BTreeMap::from([(
"a1".to_string(),
"test-token-not-real".to_string(),
)]),
..Default::default()
};
assert!(!secrets.is_empty());
}
}