feat(marketplace): 支持风格包 ZIP 下载与跨设备导入 (#942)
* feat(marketplace): add style pack ZIP downloads * fix(marketplace): reject file URI download targets
This commit is contained in:
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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user