Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/apps/cli/src/peer_host/deny.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[
"appearance_market_list_review_submissions",
"appearance_market_list_submissions",
"appearance_market_review_submission",
"appearance_market_submit_package",
"appearance_market_withdraw_submission",
"account_login",
"account_finalize_login",
Expand Down
212 changes: 207 additions & 5 deletions src/apps/desktop/src/api/appearance_market_api.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
//! Read-only Appearance marketplace commands and reviewed package download.
//! Appearance marketplace commands, reviewed package download and explicit
//! local-package submission.
//!
//! Package installation remains in the WebView-owned Appearance runtime. The
//! desktop boundary fetches public metadata and returns reviewed ZIP bytes as
//! a raw IPC response after enforcing release identity, version, size and hash.
//! Manual submission receives only an absolute controller-device path and
//! delegates package validation and upload orchestration to the shared service.

use bitfun_product_domains::appearance_market::{
validate_appearance_market_slug, AppearanceAdminSubmissionDetail, AppearanceCursorPage,
AppearanceMarketListingDetail, AppearanceMarketListingSummary, AppearanceMarketRelease,
AppearanceMarketSubmission, AppearanceMarketSubmissionStatus, AppearanceReviewDecision,
AppearanceReviewDecisionRequest, APPEARANCE_MARKET_MAX_PACKAGE_BYTES,
AppearanceMarketLicense, AppearanceMarketListingDetail, AppearanceMarketListingSummary,
AppearanceMarketRelease, AppearanceMarketSubmission, AppearanceMarketSubmissionDraftRequest,
AppearanceMarketSubmissionStatus, AppearanceReviewDecision, AppearanceReviewDecisionRequest,
APPEARANCE_MARKET_MAX_PACKAGE_BYTES,
};
use bitfun_services_integrations::appearance_market::{
AppearanceMarketBrowseRequest, AppearanceMarketClient,
resolve_appearance_release_target, submit_appearance_package, suggest_appearance_slug,
AppearanceMarketBrowseRequest, AppearanceMarketClient, AppearanceReleaseTarget,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::PathBuf;

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -48,6 +54,30 @@ pub struct AppearanceMarketReviewRequest {
pub reason: String,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppearanceMarketSubmitPackageRequest {
pub package_path: String,
#[serde(default)]
pub slug: String,
#[serde(default)]
pub min_bitfun_version: String,
#[serde(default)]
pub changelog: String,
pub license: AppearanceMarketLicense,
#[serde(default)]
pub repository_url: Option<String>,
}

struct NormalizedManualSubmission {
package_path: PathBuf,
slug: String,
min_bitfun_version: String,
changelog: String,
license: AppearanceMarketLicense,
repository_url: Option<String>,
}

#[tauri::command]
pub async fn appearance_market_browse(
request: AppearanceMarketBrowseRequest,
Expand Down Expand Up @@ -118,6 +148,58 @@ pub async fn appearance_market_list_submissions() -> Result<Vec<AppearanceMarket
client.list_submissions().await.map_err(market_error)
}

#[tauri::command]
pub async fn appearance_market_submit_package(
request: AppearanceMarketSubmitPackageRequest,
) -> Result<AppearanceMarketSubmission, String> {
let normalized = normalize_manual_submission(request)?;
let mut client = AppearanceMarketClient::from_environment()
.await
.map_err(market_error)?;
let submissions = client.list_submissions().await.map_err(market_error)?;
let (listing_id, release_number) = match resolve_appearance_release_target(
&submissions,
&normalized.slug,
) {
AppearanceReleaseTarget::NewListing => (None, 1),
AppearanceReleaseTarget::ExistingListing {
listing_id,
next_release,
} => (Some(listing_id), next_release),
AppearanceReleaseTarget::PendingReview {
submission_id,
release_number,
} => {
return Err(format!(
"Skin '{}' release {release_number} is already under review (submission {submission_id}). Withdraw it before uploading another release.",
normalized.slug
));
}
};
let changelog = if normalized.changelog.is_empty() {
if release_number == 1 {
"Initial release.".to_string()
} else {
"General updates and improvements.".to_string()
}
} else {
normalized.changelog
};
let draft = AppearanceMarketSubmissionDraftRequest {
listing_id,
slug: normalized.slug,
release_number,
min_bitfun_version: normalized.min_bitfun_version,
changelog,
license: normalized.license,
repository_url: normalized.repository_url,
};
let mut progress = |_submission_id: Option<&str>, _phase: &'static str, _done, _total| {};
submit_appearance_package(&mut client, &normalized.package_path, &draft, &mut progress)
.await
.map_err(market_error)
}

#[tauri::command]
pub async fn appearance_market_withdraw_submission(
request: AppearanceMarketSubmissionIdRequest,
Expand Down Expand Up @@ -183,6 +265,74 @@ fn validate_slug(slug: &str) -> Result<(), String> {
}
}

fn normalize_manual_submission(
request: AppearanceMarketSubmitPackageRequest,
) -> Result<NormalizedManualSubmission, String> {
let package_path = PathBuf::from(request.package_path.trim());
if !package_path.is_absolute() {
return Err("Choose an Appearance package from this device before submitting.".to_string());
}
if !package_path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.to_ascii_lowercase().ends_with(".bitfun-appearance"))
{
return Err("Skin submissions must use a .bitfun-appearance package.".to_string());
}
let fallback_name = package_path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("appearance");
let fallback_seed = package_path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("package");
let slug = if request.slug.trim().is_empty() {
suggest_appearance_slug(fallback_name, fallback_seed)
} else {
request.slug.trim().to_ascii_lowercase()
};
validate_slug(&slug)?;

let min_bitfun_version = if request.min_bitfun_version.trim().is_empty() {
env!("CARGO_PKG_VERSION").to_string()
} else {
request.min_bitfun_version.trim().to_string()
};
semver::Version::parse(&min_bitfun_version)
.map_err(|_| "Minimum BitFun version must use semantic version syntax.".to_string())?;

let spdx_expression = trimmed(request.license.spdx_expression);
let custom_url = trimmed(request.license.custom_url);
if spdx_expression.is_none() == custom_url.is_none() {
return Err("Declare exactly one SPDX expression or custom license URL.".to_string());
}
let changelog = request.changelog.trim().to_string();
if changelog.chars().count() > 2_000 {
return Err("Appearance changelogs may contain at most 2000 characters.".to_string());
}

Ok(NormalizedManualSubmission {
package_path,
slug,
min_bitfun_version,
changelog,
license: AppearanceMarketLicense {
spdx_expression,
custom_url,
},
repository_url: request
.repository_url
.and_then(|value| trimmed(Some(value))),
})
}

fn trimmed(value: Option<String>) -> Option<String> {
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
}

fn find_release(
detail: &AppearanceMarketListingDetail,
release_number: u32,
Expand Down Expand Up @@ -260,4 +410,56 @@ mod tests {
assert!(validate_slug("tokyo-night").is_ok());
assert!(validate_slug("../secret").is_err());
}

#[test]
fn normalizes_manual_submission_defaults_without_opening_the_package() {
let package_path = if cfg!(windows) {
r"C:\tmp\Ocean Night.bitfun-appearance"
} else {
"/tmp/Ocean Night.bitfun-appearance"
};
let normalized = normalize_manual_submission(AppearanceMarketSubmitPackageRequest {
package_path: package_path.to_string(),
slug: String::new(),
min_bitfun_version: String::new(),
changelog: " Initial release ".to_string(),
license: AppearanceMarketLicense {
spdx_expression: Some(" MIT ".to_string()),
custom_url: None,
},
repository_url: Some(" https://github.com/example/skin ".to_string()),
})
.expect("manual submission should normalize");

assert_eq!(normalized.slug, "ocean-night");
assert_eq!(normalized.min_bitfun_version, env!("CARGO_PKG_VERSION"));
assert_eq!(normalized.changelog, "Initial release");
assert_eq!(normalized.license.spdx_expression.as_deref(), Some("MIT"));
assert_eq!(
normalized.repository_url.as_deref(),
Some("https://github.com/example/skin")
);
}

#[test]
fn manual_submission_requires_exactly_one_license_form() {
let package_path = if cfg!(windows) {
r"C:\tmp\ocean-night.bitfun-appearance"
} else {
"/tmp/ocean-night.bitfun-appearance"
};
let result = normalize_manual_submission(AppearanceMarketSubmitPackageRequest {
package_path: package_path.to_string(),
slug: "ocean-night".to_string(),
min_bitfun_version: "0.2.15".to_string(),
changelog: String::new(),
license: AppearanceMarketLicense {
spdx_expression: None,
custom_url: None,
},
repository_url: None,
});

assert!(result.is_err_and(|error| error.contains("exactly one")));
}
}
2 changes: 2 additions & 0 deletions src/apps/desktop/src/api/peer_host_invoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[
"appearance_market_list_review_submissions",
"appearance_market_list_submissions",
"appearance_market_review_submission",
"appearance_market_submit_package",
"appearance_market_withdraw_submission",
// Account identity / peer mode control (stay on controller)
"account_login",
Expand Down Expand Up @@ -467,6 +468,7 @@ mod tests {
"appearance_market_list_review_submissions",
"appearance_market_list_submissions",
"appearance_market_review_submission",
"appearance_market_submit_package",
"appearance_market_withdraw_submission",
] {
assert!(is_local_only_command(command), "{command}");
Expand Down
4 changes: 4 additions & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
"appearance_market_list_submissions",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"appearance_market_submit_package",
RemoteWorkspacePolicy::LocalOnly,
),
(
"appearance_market_review_submission",
RemoteWorkspacePolicy::WorkspaceAgnostic,
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1694,6 +1694,7 @@ pub async fn run() {
api::appearance_market_api::appearance_market_get_listing,
api::appearance_market_api::appearance_market_download_release,
api::appearance_market_api::appearance_market_list_submissions,
api::appearance_market_api::appearance_market_submit_package,
api::appearance_market_api::appearance_market_withdraw_submission,
api::appearance_market_api::appearance_market_list_review_submissions,
api::appearance_market_api::appearance_market_get_review_submission,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const LOCAL_ONLY_COMMANDS = new Set([
'appearance_market_list_review_submissions',
'appearance_market_list_submissions',
'appearance_market_review_submission',
'appearance_market_submit_package',
'appearance_market_withdraw_submission',
'account_login',
'account_finalize_login',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,27 +52,44 @@ describe('AppearanceMarketAPI', () => {
it('uses shared-account commands for submission history and review actions', async () => {
invokeMock
.mockResolvedValueOnce([])
.mockResolvedValueOnce({ submissionId: 'submission-upload', status: 'submitted' })
.mockResolvedValueOnce({ submissionId: 'submission-1', status: 'withdrawn' })
.mockResolvedValueOnce([])
.mockResolvedValueOnce({ submission: { submissionId: 'submission-2' } })
.mockResolvedValueOnce({ submission: { submissionId: 'submission-2', status: 'approved' } });
const market = new AppearanceMarketAPI();

await market.listSubmissions();
await market.submitPackage({
packagePath: '/tmp/tokyo-night.bitfun-appearance',
slug: 'tokyo-night',
minBitfunVersion: '0.2.15',
changelog: 'Initial release',
license: { spdxExpression: 'MIT' },
});
await market.withdrawSubmission('submission-1');
await market.listReviewSubmissions();
await market.getReviewSubmission('submission-2');
await market.reviewSubmission('submission-2', 'approve');

expect(invokeMock).toHaveBeenNthCalledWith(1, 'appearance_market_list_submissions', {});
expect(invokeMock).toHaveBeenNthCalledWith(2, 'appearance_market_withdraw_submission', {
expect(invokeMock).toHaveBeenNthCalledWith(2, 'appearance_market_submit_package', {
request: {
packagePath: '/tmp/tokyo-night.bitfun-appearance',
slug: 'tokyo-night',
minBitfunVersion: '0.2.15',
changelog: 'Initial release',
license: { spdxExpression: 'MIT' },
},
}, { timeout: 300_000 });
expect(invokeMock).toHaveBeenNthCalledWith(3, 'appearance_market_withdraw_submission', {
request: { submissionId: 'submission-1' },
});
expect(invokeMock).toHaveBeenNthCalledWith(3, 'appearance_market_list_review_submissions', {});
expect(invokeMock).toHaveBeenNthCalledWith(4, 'appearance_market_get_review_submission', {
expect(invokeMock).toHaveBeenNthCalledWith(4, 'appearance_market_list_review_submissions', {});
expect(invokeMock).toHaveBeenNthCalledWith(5, 'appearance_market_get_review_submission', {
request: { submissionId: 'submission-2' },
});
expect(invokeMock).toHaveBeenNthCalledWith(5, 'appearance_market_review_submission', {
expect(invokeMock).toHaveBeenNthCalledWith(6, 'appearance_market_review_submission', {
request: { submissionId: 'submission-2', decision: 'approve', reason: '' },
});
});
Expand Down
Loading
Loading