feat(marketplace): 支持风格包 ZIP 下载与跨设备导入 (#942)

* feat(marketplace): add style pack ZIP downloads

* fix(marketplace): reject file URI download targets
This commit is contained in:
Chris233 authored and GitHub committed 2026-08-10 21:59:10 +08:00
1 parent 4cd93bd006
commit 4496eba30b
24 files changed
+594 -94

No files matched your search

@@ -0,0 +1,51 @@
package com.openless.app
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.annotation.Keep
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
/** Reads a bounded document selected through Android's Storage Access Framework. */
@Keep
object OpenLessContentReader {
private const val TAG = "OpenLessContentReader"
private const val BUFFER_BYTES = 8 * 1024
@Keep
@JvmStatic
fun readBytes(context: Context, uriString: String, maxBytes: Int): ByteArray? {
return try {
val uri = Uri.parse(uriString)
context.contentResolver.openInputStream(uri)?.use { input ->
readBounded(input, maxBytes)
} ?: run {
Log.w(TAG, "openInputStream returned null for selected document")
null
}
} catch (error: Throwable) {
Log.e(TAG, "failed to read selected document", error)
null
}
}
internal fun readBounded(input: InputStream, maxBytes: Int): ByteArray {
require(maxBytes >= 0) { "maxBytes must not be negative" }
val output = ByteArrayOutputStream(minOf(maxBytes, BUFFER_BYTES))
val buffer = ByteArray(BUFFER_BYTES)
var total = 0
while (true) {
val count = input.read(buffer)
if (count < 0) break
if (count == 0) continue
if (total > maxBytes - count) {
throw IOException("selected document exceeds $maxBytes bytes")
}
output.write(buffer, 0, count)
total += count
}
return output.toByteArray()
}
}
@@ -24,13 +24,13 @@ object OpenLessContentWriter {
output.write(bytes)
output.flush()
} ?: run {
Log.w(TAG, "openOutputStream returned null for $uriString")
Log.w(TAG, "openOutputStream returned null for selected document")
return false
}
Log.i(TAG, "wrote ${bytes.size} bytes to $uriString")
Log.i(TAG, "wrote ${bytes.size} bytes to selected document")
true
} catch (error: Throwable) {
Log.e(TAG, "failed to write $uriString", error)
Log.e(TAG, "failed to write selected document", error)
false
}
}
@@ -0,0 +1,30 @@
package com.openless.app
import java.io.ByteArrayInputStream
import java.io.IOException
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class OpenLessContentReaderTest {
private val archiveLimit = 512 * 1024
@Test
fun readBoundedAcceptsInputAtLimit() {
val bytes = ByteArray(archiveLimit) { index -> (index % 251).toByte() }
val result = OpenLessContentReader.readBounded(ByteArrayInputStream(bytes), archiveLimit)
assertArrayEquals(bytes, result)
}
@Test
fun readBoundedRejectsInputBeyondLimit() {
assertThrows(IOException::class.java) {
OpenLessContentReader.readBounded(
ByteArrayInputStream(ByteArray(archiveLimit + 1)),
archiveLimit,
)
}
}
}
@@ -44,10 +44,12 @@ const KOTLIN_FILES = [
'OpenLessAccessibilityCommandReceiver.kt',
'OverlayPermissionActivity.kt',
'OpenLessUpdateInstaller.kt',
'OpenLessContentReader.kt',
'OpenLessContentWriter.kt',
];
const KOTLIN_TEST_FILES = [
'OpenLessContentReaderTest.kt',
'OpenLessCredentialCipherTest.kt',
'OpenLessShizukuBridgeTest.kt',
'OpenLessAccessibilityResultTest.kt',
@@ -1033,6 +1033,36 @@ pub mod android {
)
}
/// Read at most `max_bytes` from a SAF `content://` URI via Kotlin ContentResolver.
pub fn read_content_uri(uri: &str, max_bytes: usize) -> Result<Vec<u8>, String> {
let max_bytes = i32::try_from(max_bytes)
.map_err(|_| "content URI byte limit exceeds Android integer range".to_string())?;
with_android_env(|env, context| {
let class =
load_context_class(env, context, "com.openless.app.OpenLessContentReader")?;
let uri_obj = jobject_str(env, uri)?;
let value = env
.call_static_method(
class,
"readBytes",
"(Landroid/content/Context;Ljava/lang/String;I)[B",
&[
JValue::Object(context),
JValue::Object(&uri_obj),
JValue::Int(max_bytes),
],
)
.and_then(|value| value.l())
.map_err(|error| format!("call OpenLessContentReader.readBytes: {error}"))?;
if value.is_null() {
return Err("read selected Android document failed".to_string());
}
let bytes = JByteArray::from(value);
env.convert_byte_array(&bytes)
.map_err(|error| format!("copy selected Android document bytes: {error}"))
})
}
/// Write `bytes` to a SAF `content://` URI via Kotlin ContentResolver.
pub fn write_content_uri(uri: &str, bytes: &[u8]) -> Result<(), String> {
with_android_env(|env, context| {
@@ -8,10 +8,11 @@ use std::io::Write;
// 写操作认证:Rust 从 CredentialsVault 读取 GitHub OAuth token 并附加
// `Authorization: Bearer`。`marketplace_dev_login` 只是前端展示缓存,不是权限来源。
//
// 5 个 IPC:
// 6 个 IPC:
// - marketplace_list 列表 + 搜索 + 排序
// - marketplace_detail 详情(含完整 prompt)
// - marketplace_install 下载 ZIP + 直接调 import_from_zip 装到本地
// - marketplace_download 校验 ZIP + 保存到用户选择的位置
// - marketplace_upload 把本地某个 style pack export ZIP → multipart 上传
// - marketplace_like 点赞
@@ -270,6 +271,15 @@ fn log_marketplace_install_failure(phase: &str, pack_id: &str, error: &str) {
);
}
fn log_marketplace_download_failure(phase: &str, pack_id: &str, error: &str) {
log::error!(
"[marketplace-download] stage=failed phase={} pack_id={} error={}",
marketplace_log_value(phase),
marketplace_log_value(pack_id),
marketplace_log_value(error),
);
}
const MARKETPLACE_INSTALL_IN_PROGRESS: &str =
"marketplace_install_in_progress: another style pack installation is already running";
@@ -482,21 +492,7 @@ pub async fn marketplace_install(
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let response = match execute_public_marketplace_with(
&base,
MarketplacePublicEndpoint::Download {
pack_id: pack_id.clone(),
},
)
.await
{
Ok(response) => response,
Err(error) => {
log_marketplace_install_failure("download", &pack_id, &error);
return Err(error);
}
};
let bytes = match read_marketplace_archive_response(response).await {
let bytes = match download_marketplace_archive_bytes(&base, &pack_id).await {
Ok(bytes) => bytes,
Err(error) => {
log_marketplace_install_failure("download", &pack_id, &error);
@@ -564,6 +560,50 @@ pub async fn marketplace_install(
}
}
#[tauri::command]
pub async fn marketplace_download(
coord: CoordinatorState<'_>,
pack_id: String,
target_path: String,
) -> Result<(), String> {
log::info!(
"[marketplace-download] stage=start pack_id={} target_kind={}",
marketplace_log_value(&pack_id),
marketplace_target_kind(&target_path)
);
if !is_valid_session_id(&pack_id) {
return Err("invalid pack id".into());
}
if target_path.trim().is_empty() {
return Err("marketplace download target is empty".into());
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let bytes = download_marketplace_archive_bytes(&base, &pack_id)
.await
.map_err(|error| {
log_marketplace_download_failure("download", &pack_id, &error);
error
})?;
crate::persistence::validate_style_pack_archive_bytes(&bytes).map_err(|error| {
let error = format!("invalid marketplace style pack archive: {error}");
log_marketplace_download_failure("validate-archive", &pack_id, &error);
error
})?;
write_marketplace_archive_target(&target_path, &bytes).map_err(|error| {
log_marketplace_download_failure("write-target", &pack_id, &error);
error
})?;
log::info!(
"[marketplace-download] stage=done pack_id={} bytes={} target_kind={}",
marketplace_log_value(&pack_id),
bytes.len(),
marketplace_target_kind(&target_path)
);
Ok(())
}
fn validate_marketplace_archive_content_length(content_length: Option<u64>) -> Result<(), String> {
if content_length.is_some_and(|length| {
length > crate::persistence::STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES as u64
@@ -607,6 +647,63 @@ async fn read_marketplace_archive_response(response: reqwest::Response) -> Resul
Ok(body)
}
async fn download_marketplace_archive_bytes(base: &str, pack_id: &str) -> Result<Vec<u8>, String> {
let response = execute_public_marketplace_with(
base,
MarketplacePublicEndpoint::Download {
pack_id: pack_id.to_string(),
},
)
.await?;
read_marketplace_archive_response(response).await
}
fn marketplace_target_kind(target_path: &str) -> &'static str {
if target_path.starts_with("content://") {
"content-uri"
} else {
"file-path"
}
}
fn write_marketplace_archive_target(target_path: &str, bytes: &[u8]) -> Result<(), String> {
#[cfg(target_os = "android")]
if target_path.starts_with("content://") {
return crate::android::jni::android::write_content_uri(target_path, bytes)
.map_err(|_| "write marketplace archive target failed".to_string());
}
if target_path.starts_with("content://") {
return Err("content URI targets are only supported on Android".to_string());
}
if target_path.starts_with("file://") {
return Err(
"file URI targets are not supported; provide a filesystem path instead".to_string(),
);
}
if target_path.trim().is_empty() {
return Err("marketplace download target is empty".to_string());
}
let path = std::path::Path::new(target_path);
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent).map_err(|error| {
format!("create marketplace archive target directory failed: {error}")
})?;
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)
.map_err(|error| format!("create marketplace archive target failed: {error}"))?;
file.write_all(bytes)
.and_then(|_| file.sync_all())
.map_err(|error| format!("write marketplace archive target failed: {error}"))
}
struct MarketplaceTempArchive {
path: std::path::PathBuf,
}
@@ -772,8 +869,8 @@ mod archive_download_tests {
use super::{
append_marketplace_archive_chunk, marketplace_log_value,
marketplace_temp_root_from_cache_dir, try_acquire_marketplace_install_lock,
validate_marketplace_archive_content_length, MarketplaceTempArchive,
MARKETPLACE_INSTALL_IN_PROGRESS,
validate_marketplace_archive_content_length, write_marketplace_archive_target,
MarketplaceTempArchive, MARKETPLACE_INSTALL_IN_PROGRESS,
};
use crate::persistence::STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES;
use std::io::Write;
@@ -814,6 +911,33 @@ mod archive_download_tests {
assert_eq!(body.len(), STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES);
}
#[test]
fn marketplace_download_target_preserves_archive_bytes() {
let root = test_root("download-target");
std::fs::create_dir_all(&root).expect("create download target root");
let target = root.join("downloaded.zip");
write_marketplace_archive_target(&target.to_string_lossy(), b"exact archive bytes")
.expect("write marketplace archive target");
assert_eq!(
std::fs::read(&target).expect("read marketplace archive target"),
b"exact archive bytes"
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn marketplace_download_target_rejects_file_uri() {
let error = write_marketplace_archive_target(
"file:///tmp/openless-marketplace-download.zip",
b"archive bytes",
)
.expect_err("file URI must not be interpreted as a filesystem path");
assert!(error.contains("file URI targets are not supported"));
}
#[test]
fn temporary_archives_are_unique_and_drop_cleans_them() {
let root = test_root("unique");
@@ -233,7 +233,25 @@ pub fn import_style_pack_from_zip(
coord: CoordinatorState<'_>,
zip_path: String,
) -> Result<StylePack, String> {
log::info!("[style-pack] command import requested zip_path={zip_path}");
log::info!(
"[style-pack] command import requested source_kind={}",
if zip_path.starts_with("content://") {
"content-uri"
} else {
"file-path"
}
);
#[cfg(target_os = "android")]
if zip_path.starts_with("content://") {
let bytes = crate::android::jni::android::read_content_uri(
&zip_path,
crate::persistence::STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES,
)?;
return coord
.style_packs()
.import_from_zip_bytes(&bytes, "Android document provider")
.map_err(|error| error.to_string());
}
coord
.style_packs()
.import_from_zip(std::path::Path::new(&zip_path))
+2
View File
@@ -194,6 +194,7 @@ macro_rules! app_invoke_handler_desktop {
commands::marketplace_list,
commands::marketplace_detail,
commands::marketplace_install,
commands::marketplace_download,
commands::marketplace_upload,
commands::marketplace_like,
commands::marketplace_my_likes,
@@ -408,6 +409,7 @@ macro_rules! app_invoke_handler_mobile {
$crate::commands::marketplace_list,
$crate::commands::marketplace_detail,
$crate::commands::marketplace_install,
$crate::commands::marketplace_download,
$crate::commands::marketplace_upload,
$crate::commands::marketplace_like,
$crate::commands::marketplace_my_likes,
@@ -47,7 +47,9 @@ pub use history::*;
pub use paths::*;
pub use preferences::*;
pub use style_pack::*;
pub(crate) use style_pack_archive::STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES;
pub(crate) use style_pack_archive::{
validate_style_pack_archive_bytes, STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES,
};
#[cfg(target_os = "android")]
pub use android_storage::init_android_storage_roots;
@@ -14,7 +14,7 @@ use uuid::Uuid;
use super::style_pack_archive::{
cleanup_style_pack_asset_dir, persist_style_pack_icon, read_style_pack_archive,
StylePackArchiveManifest,
read_style_pack_archive_bytes, ParsedStylePackArchive, StylePackArchiveManifest,
};
use super::{atomic_write, data_dir, ensure_dir, read_or_default, PreferencesStore};
use crate::types::{
@@ -293,6 +293,19 @@ impl StylePackStore {
pub fn import_from_zip(&self, zip_path: &Path) -> Result<StylePack> {
let parsed = read_style_pack_archive(zip_path)?;
self.import_parsed_archive(parsed, &zip_path.display().to_string())
}
pub fn import_from_zip_bytes(&self, bytes: &[u8], source: &str) -> Result<StylePack> {
let parsed = read_style_pack_archive_bytes(bytes)?;
self.import_parsed_archive(parsed, source)
}
fn import_parsed_archive(
&self,
parsed: ParsedStylePackArchive,
source: &str,
) -> Result<StylePack> {
let manifest = parsed.manifest;
let manifest_id = manifest.id.clone();
@@ -343,7 +356,7 @@ impl StylePackStore {
*packs = next_packs;
log::info!(
"[style-pack] imported source={} installed_id={} manifest_id={} base_mode={:?} prompt_chars={} examples={} tags={} icon={}",
zip_path.display(),
source,
pack.id,
manifest_id,
pack.base_mode,
@@ -78,9 +78,20 @@ pub(super) struct StreamBudget {
pub(super) fn read_style_pack_archive(zip_path: &Path) -> Result<ParsedStylePackArchive> {
let compressed = read_compressed_archive_bounded(zip_path)?;
let mut archive = zip::ZipArchive::new(Cursor::new(compressed.as_slice()))
.context("open style pack zip archive")?;
preflight_physical_central_directory(&compressed, &archive)?;
read_style_pack_archive_bytes(&compressed)
}
pub(super) fn read_style_pack_archive_bytes(compressed: &[u8]) -> Result<ParsedStylePackArchive> {
if compressed.len() > STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES {
bail!(
"style pack archive compressed size {} exceeds {} bytes",
compressed.len(),
STYLE_PACK_ARCHIVE_MAX_COMPRESSED_BYTES
);
}
let mut archive =
zip::ZipArchive::new(Cursor::new(compressed)).context("open style pack zip archive")?;
preflight_physical_central_directory(compressed, &archive)?;
let entry_names = preflight_archive_metadata(&mut archive)?;
let mut stream_budget = StreamBudget::default();
@@ -154,6 +165,10 @@ pub(super) fn read_style_pack_archive(zip_path: &Path) -> Result<ParsedStylePack
})
}
pub(crate) fn validate_style_pack_archive_bytes(compressed: &[u8]) -> Result<()> {
read_style_pack_archive_bytes(compressed).map(|_| ())
}
const CENTRAL_DIRECTORY_HEADER_SIGNATURE: &[u8; 4] = b"PK\x01\x02";
const ZIP64_END_SIGNATURE: &[u8; 4] = b"PK\x06\x06";
const ZIP64_LOCATOR_SIGNATURE: &[u8; 4] = b"PK\x06\x07";
@@ -497,6 +497,24 @@ fn style_pack_archive_round_trip_preserves_valid_pack_and_png_icon() {
);
}
#[test]
fn style_pack_archive_bytes_can_be_imported_from_a_document_provider() {
let root = TestDir::new("bytes-import");
let zip_path = root.path().join("document-provider.zip");
valid_archive(&zip_path, None);
let bytes = fs::read(&zip_path).expect("read archive bytes");
let destination = test_store(&root.path().join("destination"), Vec::new());
let imported = destination
.import_from_zip_bytes(&bytes, "document provider")
.expect("import valid archive bytes");
assert_eq!(imported.id, "test-pack");
assert_eq!(imported.name, "Test Pack");
assert_eq!(imported.prompt, "Write clearly and concisely.");
assert_eq!(imported.examples.len(), 1);
}
#[test]
fn migration_fills_empty_selection_prompts_with_style_defaults() {
let mut packs = builtin_style_packs();
+5
View File
@@ -134,8 +134,12 @@ export const en: typeof zhCN = {
noDescription: '(no description)',
installBtn: 'Install',
installingBtn: 'Installing…',
downloadZipBtn: 'Download ZIP',
downloadingZipBtn: 'Downloading…',
downloadAria: 'Download "{{name}}" ZIP',
likeBtn: 'Like',
installed: 'Installed "{{name}}" locally',
downloaded: 'Downloaded "{{name}}" ZIP',
uploaded: 'Uploaded — waiting for review',
uploadTitle: 'Pick a style pack to upload',
uploadHint: 'Uploading as {{login}}. Content goes to the cloud review queue.',
@@ -143,6 +147,7 @@ export const en: typeof zhCN = {
errors: {
detail: 'Detail load failed: {{err}}',
install: 'Install failed: {{err}}',
download: 'ZIP download failed: {{err}}',
like: 'Like failed: {{err}}',
upload: 'Upload failed: {{err}}',
loadLocal: 'Load local packs failed: {{err}}',
+5
View File
@@ -136,8 +136,12 @@ export const ja: typeof zhCN = {
noDescription: '(説明なし)',
installBtn: 'インストール',
installingBtn: 'インストール中…',
downloadZipBtn: 'ZIP をダウンロード',
downloadingZipBtn: 'ダウンロード中…',
downloadAria: '「{{name}}」の ZIP をダウンロード',
likeBtn: 'いいね',
installed: '「{{name}}」をローカルにインストールしました',
downloaded: '「{{name}}」の ZIP をダウンロードしました',
uploaded: 'アップロード完了、審査中',
uploadTitle: 'アップロードするパックを選択',
uploadHint: '{{login}} としてアップロードします。内容はクラウド審査キューに送信されます。',
@@ -145,6 +149,7 @@ export const ja: typeof zhCN = {
errors: {
detail: '詳細の読み込み失敗:{{err}}',
install: 'インストール失敗:{{err}}',
download: 'ZIP のダウンロード失敗:{{err}}',
like: 'いいね失敗:{{err}}',
upload: 'アップロード失敗:{{err}}',
loadLocal: 'ローカルパック読み込み失敗:{{err}}',
+5
View File
@@ -136,8 +136,12 @@ export const ko: typeof zhCN = {
noDescription: '(설명 없음)',
installBtn: '설치',
installingBtn: '설치 중…',
downloadZipBtn: 'ZIP 다운로드',
downloadingZipBtn: '다운로드 중…',
downloadAria: '"{{name}}" ZIP 다운로드',
likeBtn: '좋아요',
installed: '"{{name}}"을(를) 로컬에 설치했습니다',
downloaded: '"{{name}}" ZIP을 다운로드했습니다',
uploaded: '업로드 완료, 심사 대기 중',
uploadTitle: '업로드할 팩 선택',
uploadHint: '{{login}}(으)로 업로드합니다. 콘텐츠는 클라우드 심사 큐로 전송됩니다.',
@@ -145,6 +149,7 @@ export const ko: typeof zhCN = {
errors: {
detail: '상세 불러오기 실패: {{err}}',
install: '설치 실패: {{err}}',
download: 'ZIP 다운로드 실패: {{err}}',
like: '좋아요 실패: {{err}}',
upload: '업로드 실패: {{err}}',
loadLocal: '로컬 팩 불러오기 실패: {{err}}',
+5
View File
@@ -132,8 +132,12 @@ export const zhCN = {
noDescription: '(暂无描述)',
installBtn: '安装到本地',
installingBtn: '安装中…',
downloadZipBtn: '下载 ZIP',
downloadingZipBtn: '下载中…',
downloadAria: '下载「{{name}}」ZIP',
likeBtn: '点赞',
installed: '已安装「{{name}}」到本地风格包',
downloaded: '已下载「{{name}}」ZIP',
uploaded: '上传成功,等待审核',
uploadTitle: '选择要上传的风格包',
uploadHint: '上传以 {{login}} 身份登录。包内容会发到云端审核队列。',
@@ -141,6 +145,7 @@ export const zhCN = {
errors: {
detail: '加载详情失败:{{err}}',
install: '安装失败:{{err}}',
download: '下载 ZIP 失败:{{err}}',
like: '点赞失败:{{err}}',
upload: '上传失败:{{err}}',
loadLocal: '加载本地风格包失败:{{err}}',
+5
View File
@@ -134,8 +134,12 @@ export const zhTW: typeof zhCN = {
noDescription: '(暫無描述)',
installBtn: '安裝到本機',
installingBtn: '安裝中…',
downloadZipBtn: '下載 ZIP',
downloadingZipBtn: '下載中…',
downloadAria: '下載「{{name}}」ZIP',
likeBtn: '點讚',
installed: '已安裝「{{name}}」到本機風格包',
downloaded: '已下載「{{name}}」ZIP',
uploaded: '上傳成功,等待審核',
uploadTitle: '選擇要上傳的風格包',
uploadHint: '以 {{login}} 身份上傳。包內容會發送到雲端審核佇列。',
@@ -143,6 +147,7 @@ export const zhTW: typeof zhCN = {
errors: {
detail: '載入詳情失敗:{{err}}',
install: '安裝失敗:{{err}}',
download: '下載 ZIP 失敗:{{err}}',
like: '點讚失敗:{{err}}',
upload: '上傳失敗:{{err}}',
loadLocal: '載入本機風格包失敗:{{err}}',
+1
View File
@@ -208,6 +208,7 @@ export {
listMarketplace,
fetchMarketplaceDetail,
installMarketplacePack,
downloadMarketplacePack,
uploadMarketplacePack,
likeMarketplacePack,
marketplaceMyLikes,
@@ -43,6 +43,17 @@ export function installMarketplacePack(packId: string): Promise<StylePack> {
)
}
export function downloadMarketplacePack(
packId: string,
targetPath: string,
): Promise<void> {
return invokeOrMock(
"marketplace_download",
{ packId, targetPath },
() => undefined,
)
}
export function uploadMarketplacePack(
packId: string,
originPackId?: string | null,
@@ -0,0 +1,12 @@
import { downloadMarketplacePack } from "./ipc/marketplace"
const result = await downloadMarketplacePack(
"00000000-0000-0000-0000-000000000001",
"~/Downloads/demo-pack.zip",
)
if (result !== undefined) {
throw new Error("browser development mode should complete downloads through its mock boundary")
}
console.log("marketplaceDownload tests passed")
@@ -0,0 +1,55 @@
import {
isStylePackZipDialogCancellation,
pickStylePackZipTargetPath,
stylePackZipFileName,
} from "./stylePackZip"
function assertEqual<T>(actual: T, expected: T, message: string): void {
if (actual !== expected) {
throw new Error(`${message}: expected ${String(expected)}, received ${String(actual)}`)
}
}
assertEqual(
stylePackZipFileName("会议记录", "1.2.0"),
"会议记录-v1.2.0.zip",
"marketplace downloads should include a readable versioned file name",
)
assertEqual(
stylePackZipFileName("Quarterly: Review / Final", "1/2"),
"quarterly-review-final-v1-2.zip",
"file names should remove path separators and collapse punctuation gaps",
)
assertEqual(
stylePackZipFileName("My Pack"),
"my-pack.zip",
"local style exports should preserve their existing unversioned naming",
)
assertEqual(
stylePackZipFileName(" ", " "),
"style-pack.zip",
"blank metadata should still produce a usable ZIP file name",
)
assertEqual(
isStylePackZipDialogCancellation(new Error("File picker cancelled")),
true,
"Android dialog cancellation should be treated as a silent user action",
)
assertEqual(
isStylePackZipDialogCancellation("Failed to save file"),
false,
"real save failures should still be surfaced",
)
assertEqual(
await pickStylePackZipTargetPath("demo.zip", false),
"~/Downloads/demo.zip",
"browser development mode should keep the existing mock destination",
)
console.log("stylePackZip tests passed")
+41
View File
@@ -0,0 +1,41 @@
const INVALID_FILE_NAME_CHARS = /[<>:"/\\|?*\u0000-\u001f]+/g
function normalizeFileNamePart(value: string): string {
return value
.trim()
.replace(INVALID_FILE_NAME_CHARS, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^[.\-\s]+|[.\-\s]+$/g, "")
.toLowerCase()
}
export function stylePackZipFileName(name: string, version?: string): string {
const baseName = normalizeFileNamePart(name) || "style-pack"
const normalizedVersion = normalizeFileNamePart(version ?? "").replace(/^v(?=\d)/, "")
const versionSuffix = normalizedVersion ? `-v${normalizedVersion}` : ""
return `${baseName}${versionSuffix}.zip`
}
export function isStylePackZipDialogCancellation(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /\bcancel(?:l)?ed\b/i.test(message)
}
export async function pickStylePackZipTargetPath(
defaultFileName: string,
nativeApp: boolean,
): Promise<string | null> {
if (!nativeApp) return `~/Downloads/${defaultFileName}`
try {
const { save } = await import("@tauri-apps/plugin-dialog")
return await save({
defaultPath: defaultFileName,
filters: [{ name: "Style Pack ZIP", extensions: ["zip"] }],
})
} catch (error) {
if (isStylePackZipDialogCancellation(error)) return null
throw error
}
}
+113 -50
View File
@@ -20,8 +20,10 @@ import { ThinkingDots } from '../components/ThinkingDots';
import { GithubLoginModal } from '../components/GithubLoginModal';
import { Modal } from '../components/ui/Modal';
import {
downloadMarketplacePack,
fetchMarketplaceDetail,
installMarketplacePack,
isTauri,
likeMarketplacePack,
listMarketplace,
listStylePacks,
@@ -45,6 +47,7 @@ import {
shouldCloseMarketplaceDetail,
type MarketplaceInstallError,
} from '../lib/marketplaceInstall';
import { pickStylePackZipTargetPath, stylePackZipFileName } from '../lib/stylePackZip';
import { Btn, Card, PageHeader, Pill } from './_atoms';
type SortMode = 'popular' | 'new' | 'liked';
@@ -65,6 +68,7 @@ export function Marketplace() {
const [detailLoading, setDetailLoading] = useState(false);
const [actionMsg, setActionMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const [installingPackId, setInstallingPackId] = useState<string | null>(null);
const [downloadingPackId, setDownloadingPackId] = useState<string | null>(null);
const [installError, setInstallError] = useState<MarketplaceInstallError | null>(null);
const [showUpload, setShowUpload] = useState(false);
@@ -292,6 +296,22 @@ export function Marketplace() {
}
};
const onDownload = async (pack: MarketplaceListItem) => {
if (downloadingPackId !== null) return;
setDownloadingPackId(pack.id);
try {
const defaultName = stylePackZipFileName(pack.name, pack.version);
const targetPath = await pickStylePackZipTargetPath(defaultName, isTauri);
if (!targetPath) return;
await downloadMarketplacePack(pack.id, targetPath);
setActionMsg({ kind: 'ok', text: t('marketplace.downloaded', { name: pack.name }) });
} catch (error) {
setActionMsg({ kind: 'err', text: t('marketplace.errors.download', { err: errorMessage(error) }) });
} finally {
setDownloadingPackId(current => current === pack.id ? null : current);
}
};
const onLike = async () => {
if (!detail) return;
if (!marketplaceSignedIn) {
@@ -624,56 +644,99 @@ export function Marketplace() {
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12 }}>
<AnimatePresence mode="sync">
{visibleItems.map(p => (
<motion.button
layout
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{
layout: { type: 'spring', damping: 25, stiffness: 220 },
opacity: { duration: 0.2 },
scale: { duration: 0.2 }
}}
key={p.id}
onClick={() => void openDetail(p.id)}
style={{
textAlign: 'left',
padding: 14,
borderRadius: 12,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 6 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink-1)' }}>{p.name}</span>
<span style={{ fontSize: 10, color: 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-mono)' }}>v{p.version}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', minHeight: 36 }}>
{p.description || t('marketplace.noDescription')}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 2 }}>
<Pill size="sm" tone="outline">{p.baseMode}</Pill>
{isDerivative(p.originAuthorLogin) && (
<span title={t('marketplace.derivativeBadge', { login: p.originAuthorLogin })}>
<Pill size="sm" tone="ok">{t('marketplace.derivativeBadge', { login: p.originAuthorLogin })}</Pill>
</span>
)}
{p.tags.slice(0, 2).map(tag => <Pill key={tag} size="sm" tone="default">{tag}</Pill>)}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 4 }}>
<span style={{ fontWeight: 500, color: 'var(--ol-ink-3)' }}>@{p.authorLogin}</span>
<span>
<span style={{ color: likedIds.has(p.id) ? '#ef4444' : 'var(--ol-ink-4)' }}>{likedIds.has(p.id) ? '★' : '☆'}</span>
{' '}{p.likeCount} · ↓ {p.downloadCount}
</span>
</div>
</motion.button>
))}
{visibleItems.map(p => {
const isDownloading = downloadingPackId === p.id;
return (
<motion.article
layout
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{
layout: { type: 'spring', damping: 25, stiffness: 220 },
opacity: { duration: 0.2 },
scale: { duration: 0.2 }
}}
key={p.id}
style={{
borderRadius: 12,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<button
type="button"
onClick={() => void openDetail(p.id)}
style={{
width: '100%',
flex: 1,
textAlign: 'left',
padding: 14,
border: 'none',
background: 'transparent',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 6 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink-1)' }}>{p.name}</span>
<span style={{ fontSize: 10, color: 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-mono)' }}>v{p.version}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', minHeight: 36 }}>
{p.description || t('marketplace.noDescription')}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 2 }}>
<Pill size="sm" tone="outline">{p.baseMode}</Pill>
{isDerivative(p.originAuthorLogin) && (
<span title={t('marketplace.derivativeBadge', { login: p.originAuthorLogin })}>
<Pill size="sm" tone="ok">{t('marketplace.derivativeBadge', { login: p.originAuthorLogin })}</Pill>
</span>
)}
{p.tags.slice(0, 2).map(tag => <Pill key={tag} size="sm" tone="default">{tag}</Pill>)}
</div>
</button>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8, padding: '0 14px 12px', fontSize: 11, color: 'var(--ol-ink-4)' }}>
<span style={{ fontWeight: 500, color: 'var(--ol-ink-3)' }}>@{p.authorLogin}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>
<span style={{ color: likedIds.has(p.id) ? '#ef4444' : 'var(--ol-ink-4)' }}>{likedIds.has(p.id) ? '★' : '☆'}</span>
{' '}{p.likeCount} · ↓ {p.downloadCount}
</span>
<button
type="button"
disabled={downloadingPackId !== null}
aria-label={t('marketplace.downloadAria', { name: p.name })}
title={t('marketplace.downloadAria', { name: p.name })}
onClick={() => void onDownload(p)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '4px 7px',
borderRadius: 7,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface-2)',
color: 'var(--ol-ink-2)',
cursor: downloadingPackId === null ? 'pointer' : 'not-allowed',
opacity: downloadingPackId !== null && !isDownloading ? 0.5 : 1,
fontSize: 10,
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
<Icon name="download" size={12} />
{isDownloading ? t('marketplace.downloadingZipBtn') : t('marketplace.downloadZipBtn')}
</button>
</div>
</div>
</motion.article>
);
})}
</AnimatePresence>
</div>
)}
+3 -16
View File
@@ -20,6 +20,7 @@ import type { PolishMode, StylePack, StylePackExample, StylePackRuntimeDiagnosti
import { Btn, Card, PageHeader, Pill } from './_atoms';
import { Icon } from '../components/Icon';
import { SavedToast, type SaveToastState } from '../components/SavedToast';
import { pickStylePackZipTargetPath, stylePackZipFileName } from '../lib/stylePackZip';
type BusyAction =
| 'loading'
@@ -127,11 +128,6 @@ function modeTone(mode: PolishMode): 'default' | 'blue' | 'ok' | 'outline' | 'da
return 'dark';
}
function sanitizeZipFileName(name: string) {
const trimmed = name.trim() || 'style-pack';
return trimmed.replace(/[<>:"/\\|?*]+/g, '-').replace(/\s+/g, '-').toLowerCase();
}
export function Style() {
const { t } = useTranslation();
const { prefs: marketplacePrefs, updatePrefs: updateMarketplacePrefs } = useHotkeySettings();
@@ -542,17 +538,8 @@ export function Style() {
}
setBusy('exporting');
try {
const defaultName = `${sanitizeZipFileName(pack.name)}.zip`;
let targetPath: string | null = null;
if (isTauri) {
const { save } = await import('@tauri-apps/plugin-dialog');
targetPath = await save({
defaultPath: defaultName,
filters: [{ name: 'Style Pack ZIP', extensions: ['zip'] }],
});
} else {
targetPath = `~/Downloads/${defaultName}`;
}
const defaultName = stylePackZipFileName(pack.name);
const targetPath = await pickStylePackZipTargetPath(defaultName, isTauri);
if (!targetPath) {
setBusy(null);
return;