diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index c9bf14efc..3e1b30783 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -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", diff --git a/src/apps/desktop/src/api/appearance_market_api.rs b/src/apps/desktop/src/api/appearance_market_api.rs index 31863d478..1341a9f78 100644 --- a/src/apps/desktop/src/api/appearance_market_api.rs +++ b/src/apps/desktop/src/api/appearance_market_api.rs @@ -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")] @@ -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, +} + +struct NormalizedManualSubmission { + package_path: PathBuf, + slug: String, + min_bitfun_version: String, + changelog: String, + license: AppearanceMarketLicense, + repository_url: Option, +} + #[tauri::command] pub async fn appearance_market_browse( request: AppearanceMarketBrowseRequest, @@ -118,6 +148,58 @@ pub async fn appearance_market_list_submissions() -> Result Result { + 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, @@ -183,6 +265,74 @@ fn validate_slug(slug: &str) -> Result<(), String> { } } +fn normalize_manual_submission( + request: AppearanceMarketSubmitPackageRequest, +) -> Result { + 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) -> Option { + value + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) +} + fn find_release( detail: &AppearanceMarketListingDetail, release_number: u32, @@ -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"))); + } } diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index ca58998d4..39caa89d1 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -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", @@ -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}"); diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 7f2419148..d7df25d14 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -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, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 68542ec83..8b8b286af 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -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, diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index 7256cfe71..1dc8c34ae 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -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', diff --git a/src/web-ui/src/infrastructure/api/service-api/AppearanceMarketAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/AppearanceMarketAPI.test.ts index 11c8b0333..9314377a1 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AppearanceMarketAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AppearanceMarketAPI.test.ts @@ -52,6 +52,7 @@ 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' } }) @@ -59,20 +60,36 @@ describe('AppearanceMarketAPI', () => { 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: '' }, }); }); diff --git a/src/web-ui/src/infrastructure/api/service-api/AppearanceMarketAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AppearanceMarketAPI.ts index cd86cbbe1..b9e1a8e42 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AppearanceMarketAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AppearanceMarketAPI.ts @@ -115,6 +115,15 @@ export interface AppearanceMarketDownloadRequest { packageSize: number; } +export interface AppearanceMarketSubmitPackageRequest { + packagePath: string; + slug?: string; + minBitfunVersion?: string; + changelog?: string; + license: AppearanceMarketLicense; + repositoryUrl?: string; +} + function isolatedArrayBuffer(value: ArrayBuffer | Uint8Array): ArrayBuffer { if (value instanceof ArrayBuffer) return value.slice(0); if (ArrayBuffer.isView(value)) { @@ -167,6 +176,34 @@ export class AppearanceMarketAPI { } } + async chooseSubmissionPackage(title: string): Promise { + const { open } = await import('@tauri-apps/plugin-dialog'); + const selected = await open({ + directory: false, + multiple: false, + title, + filters: [{ name: 'BitFun Appearance', extensions: ['bitfun-appearance'] }], + }); + return typeof selected === 'string' && selected.length > 0 ? selected : null; + } + + async submitPackage( + request: AppearanceMarketSubmitPackageRequest, + ): Promise { + try { + return await api.invoke( + 'appearance_market_submit_package', + { request }, + { timeout: 300_000 }, + ); + } catch (error) { + throw createTauriCommandError('appearance_market_submit_package', error, { + ...request, + packagePath: request.packagePath.split(/[\\/]/).pop() ?? '', + }); + } + } + async withdrawSubmission(submissionId: string): Promise { try { return await api.invoke('appearance_market_withdraw_submission', { diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceConfig.scss b/src/web-ui/src/infrastructure/config/components/AppearanceConfig.scss index 5a9d2e67d..c6719d5fa 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/AppearanceConfig.scss @@ -492,6 +492,56 @@ } } + &__workflow-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: $size-gap-2; + } + + &__manual-submit { + display: grid; + gap: $size-gap-3; + margin-bottom: $size-gap-4; + padding: $size-gap-4; + border: 1px solid var(--bf-appearance-token-border-base); + border-radius: $size-radius-base; + background: var(--bf-appearance-token-color-bg-secondary); + } + + &__manual-submit-heading { + strong { + font-size: var(--bf-appearance-token-font-size-sm); + } + + p { + margin: 3px 0 0; + color: var(--bf-appearance-token-color-text-muted); + font-size: var(--bf-appearance-token-font-size-xs); + line-height: 1.5; + } + } + + &__manual-package-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: $size-gap-2; + } + + &__manual-submit-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: $size-gap-3; + } + + &__manual-submit-actions { + display: flex; + justify-content: flex-end; + gap: $size-gap-2; + padding-top: $size-gap-1; + } + &__submission-list { display: grid; gap: $size-gap-2; @@ -499,6 +549,22 @@ padding-right: $size-gap-1; } + @media (max-width: 760px) { + &__workflow-heading { + flex-direction: column; + } + + &__workflow-actions { + width: 100%; + justify-content: flex-end; + } + + &__manual-package-row, + &__manual-submit-grid { + grid-template-columns: 1fr; + } + } + &__submission { display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx index 08acba73f..7fdbc241d 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx @@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({ getListing: vi.fn(), downloadRelease: vi.fn(), listSubmissions: vi.fn(), + chooseSubmissionPackage: vi.fn(), + submitPackage: vi.fn(), withdrawSubmission: vi.fn(), listReviewSubmissions: vi.fn(), getReviewSubmission: vi.fn(), @@ -52,7 +54,10 @@ vi.mock('@/component-library', () => ({ /> ), Select: () =>
, - Textarea: ({ label, ...props }: any) =>