diff --git a/.gitignore b/.gitignore index 4226f59a..023f2375 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ .mypy_cache/ .ruff_cache/ apps/desktop/src-tauri/target/ +/target/ *.pyc *.pyo *.egg-info/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..eb6c2e02 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,586 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bandscope-desktop-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "time", + "url", + "uuid", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..5b0b0feb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +# Root Cargo workspace manifest. +# +# The workspace contains the GUI-independent `bandscope-desktop-core` crate so +# that coverage tooling (e.g. `cargo llvm-cov`) can locate a root manifest and +# run the Rust test suite from the workspace root on any platform. +# +# The Tauri desktop binary (apps/desktop/src-tauri) is intentionally excluded: +# it depends on `wry`/webkit and a bundled frontend, so it is built on its own +# with `--manifest-path apps/desktop/src-tauri/Cargo.toml` (see ci.yml). It +# consumes `bandscope-desktop-core` as a path dependency. +[workspace] +resolver = "2" +members = ["apps/desktop/core"] +exclude = ["apps/desktop/src-tauri"] diff --git a/apps/desktop/core/Cargo.toml b/apps/desktop/core/Cargo.toml new file mode 100644 index 00000000..19cda59c --- /dev/null +++ b/apps/desktop/core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "bandscope-desktop-core" +version = "0.1.0" +edition = "2021" +description = "GUI-independent payload contracts and validation logic for the BandScope desktop app." +publish = false + +[lib] +name = "bandscope_desktop_core" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +time = { version = "0.3", features = ["formatting", "macros"] } +url = "2.5.8" + +[dev-dependencies] +uuid = { version = "1", features = ["v4"] } diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs new file mode 100644 index 00000000..bcaa3205 --- /dev/null +++ b/apps/desktop/core/src/lib.rs @@ -0,0 +1,1092 @@ +//! Pure, GUI-independent logic for the BandScope desktop app. +//! +//! This crate holds every payload contract, validation guard, and process +//! helper that does not depend on Tauri or the WebView runtime. Keeping it +//! free of `tauri`/`wry` lets the full unit-test suite build and run (and be +//! measured for coverage) on any platform without a windowing system or a +//! bundled frontend. + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use std::{ + collections::HashMap, + io::Read, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::{ + atomic::{AtomicU64, AtomicUsize, Ordering}, + Arc, Mutex, + }, + thread, + time::{Duration, Instant}, +}; +use time::OffsetDateTime; + +#[derive(Clone)] +pub struct AppState(pub Arc); + +pub struct AppStateInner { + pub next_job: AtomicU64, + pub in_flight_jobs: AtomicUsize, + pub jobs: Mutex>, + pub bootstrap_sources: Mutex>, +} + +pub const MAX_IN_FLIGHT_JOBS: usize = 2; + +pub const ANALYSIS_PROCESS_TIMEOUT: Duration = Duration::from_secs(30); + +pub const ANALYSIS_WAIT_POLL: Duration = Duration::from_millis(50); + +pub const AUDIO_EXTENSIONS: [&str; 4] = ["wav", "mp3", "flac", "m4a"]; + +pub const MISSING_ANALYSIS_PYTHON: &str = "__bandscope_missing_analysis_python__"; + +pub const YOUTUBE_IMPORT_TIMEOUT: Duration = Duration::from_secs(120); + +pub const MAX_SCORE_PDF_BYTES: u64 = 25 * 1024 * 1024; + +pub const PDF_MAGIC: &[u8] = b"%PDF-"; + +impl Default for AppState { + fn default() -> Self { + Self(Arc::new(AppStateInner { + next_job: AtomicU64::new(1), + in_flight_jobs: AtomicUsize::new(0), + jobs: Mutex::new(HashMap::new()), + bootstrap_sources: Mutex::new(HashMap::new()), + })) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AnalysisJobRequest { + pub source_kind: String, + pub project_id: Option, + pub source_label: String, + pub role_focus: Vec, + pub local_source: Option, + pub cache_root: Option, + pub temp_root: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisJobErrorCode { + InvalidRequest, + NotFound, + EngineUnavailable, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AnalysisJobError { + pub code: AnalysisJobErrorCode, + pub message: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisJobState { + Queued, + Running, + Succeeded, + Failed, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisJobStage { + Queued, + Decode, + Separate, + Analyze, + Persist, + Ready, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisCacheStatus { + Disabled, + Miss, + Hit, + Stored, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalSongPayload { + id: String, + title: String, + sections: Vec, + export_summary: ExportSummaryPayload, + #[serde(default, skip_serializing_if = "Option::is_none")] + score_attachments: Option>, +} + +/// Score attachment metadata persisted inside the song payload. Only the +/// locally minted score id and the display file name cross the IPC boundary; +/// the PDF bytes stay in the app-owned scores directory keyed by that id. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ScoreAttachmentMetadataPayload { + id: String, + file_name: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ConfidencePayload { + level: String, + source: String, + notes: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CuePayload { + kind: String, + value: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RangePayload { + lowest_note: String, + highest_note: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HarmonyPayload { + chord: String, + function_label: String, + source: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ManualOverridePayload { + field: String, + value: HarmonyPayload, + source: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalRolePayload { + id: String, + name: String, + role_type: String, + harmony: HarmonyPayload, + cue: CuePayload, + range: RangePayload, + confidence: ConfidencePayload, + rehearsal_priority: String, + simplification: String, + setup_note: String, + manual_overrides: Vec, + overlap_warnings: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SectionTimeRangePayload { + start: u32, + end: u32, +} + +impl<'de> Deserialize<'de> for SectionTimeRangePayload { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct RawSectionTimeRangePayload { + start: u32, + end: u32, + } + + let raw = RawSectionTimeRangePayload::deserialize(deserializer)?; + if raw.end <= raw.start { + return Err(serde::de::Error::custom( + "section timeRange end must be greater than start", + )); + } + + Ok(Self { + start: raw.start, + end: raw.end, + }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PartGraphNodePayload { + role_id: String, + is_active: bool, + handoff_to: Vec, + handoff_from: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RehearsalSectionPayload { + id: String, + label: String, + groove: String, + time_range: SectionTimeRangePayload, + confidence: ConfidencePayload, + roles: Vec, + part_graph: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExportSummaryPayload { + format: String, + headline: String, + focus_sections: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AnalysisJobStatus { + pub job_id: String, + pub state: AnalysisJobState, + pub requested_at: String, + pub updated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub progress_label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub progress_stage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub progress_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LocalAudioSourcePayload { + pub source_path: String, + pub file_name: String, + pub extension: String, + pub file_size_bytes: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProjectBootstrapSummaryPayload { + pub project_id: String, + pub source_mode: String, + pub project_root: String, + pub cache_root: String, + pub temp_root: String, + pub source: LocalAudioSourcePayload, +} + +pub fn next_project_id(state: &AppState) -> String { + format!( + "project-{}-{}", + OffsetDateTime::now_utc().unix_timestamp_nanos(), + state.0.next_job.fetch_add(1, Ordering::Relaxed) + ) +} + +pub fn youtube_source_from_metadata( + metadata: &Value, + cache_root: &Path, +) -> Result { + let filepath = metadata + .get("filepath") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Failed to parse YouTube import response.".to_string())?; + let title = metadata + .get("title") + .and_then(|value| value.as_str()) + .unwrap_or("Unknown YouTube Audio"); + let path = Path::new(filepath); + let link_metadata = std::fs::symlink_metadata(path) + .map_err(|_| "Could not read downloaded audio file.".to_string())?; + if link_metadata.file_type().is_symlink() { + return Err("YouTube import returned an invalid audio path.".to_string()); + } + + let canonical_cache_root = cache_root + .canonicalize() + .map_err(|_| "Could not validate YouTube import workspace.".to_string())?; + let canonical = path + .canonicalize() + .map_err(|_| "Could not read downloaded audio file.".to_string())?; + if !canonical.starts_with(&canonical_cache_root) { + return Err("YouTube import returned an invalid audio path.".to_string()); + } + + let file_metadata = std::fs::metadata(&canonical) + .map_err(|_| "Could not read downloaded audio file.".to_string())?; + if !file_metadata.is_file() || file_metadata.len() == 0 { + return Err("YouTube import returned an invalid audio file.".to_string()); + } + + let extension = canonical + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .ok_or_else(|| "YouTube import returned an unsupported audio format.".to_string())?; + if !AUDIO_EXTENSIONS.contains(&extension.as_str()) { + return Err("YouTube import returned an unsupported audio format.".to_string()); + } + + let safe_title: String = title + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '.' => '_', + c if c.is_control() => '_', + c => c, + }) + .take(100) + .collect(); + let safe_title = if safe_title.is_empty() { + "youtube_audio".to_string() + } else { + safe_title + }; + + Ok(LocalAudioSourcePayload { + source_path: canonical.to_string_lossy().into_owned(), + file_name: format!("{safe_title}.{extension}"), + extension, + file_size_bytes: file_metadata.len(), + }) +} + +pub fn is_supported_youtube_url(url: &str) -> bool { + let parsed_url = match url::Url::parse(url) { + Ok(u) => u, + Err(_) => return false, + }; + if parsed_url.scheme() != "https" { + return false; + } + + let host = parsed_url.host_str().unwrap_or("").to_lowercase(); + if host == "youtu.be" { + let mut segments = match parsed_url.path_segments() { + Some(s) => s.filter(|segment| !segment.is_empty()), + None => return false, + }; + let Some(video_id) = segments.next() else { + return false; + }; + return is_youtube_video_id(video_id) && segments.next().is_none(); + } + + if host == "youtube.com" || host == "www.youtube.com" { + if parsed_url.path() != "/watch" { + return false; + } + let mut video_ids = parsed_url + .query_pairs() + .filter(|(key, _)| key == "v") + .map(|(_, value)| value); + return match (video_ids.next(), video_ids.next()) { + (Some(video_id), None) => is_youtube_video_id(video_id.as_ref()), + _ => false, + }; + } + + false +} + +pub fn youtube_missing_metadata_error(_parsed: &Value) -> String { + "YouTube import reported ok but missing metadata.".to_string() +} + +pub fn wait_for_process_output( + mut command: Command, + timeout: Duration, + poll_interval: Duration, + timeout_message: &str, +) -> Result { + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| "Failed to start YouTube import process.".to_string())?; + let Some(stdout) = child.stdout.take() else { + let _ = child.kill(); + let _ = child.wait(); + return Err("Failed to execute YouTube import process.".to_string()); + }; + let Some(stderr) = child.stderr.take() else { + let _ = child.kill(); + let _ = child.wait(); + return Err("Failed to execute YouTube import process.".to_string()); + }; + let stdout_reader = thread::spawn(move || { + let mut reader = stdout; + let mut buffer = Vec::new(); + reader.read_to_end(&mut buffer).map(|_| buffer) + }); + let stderr_reader = thread::spawn(move || { + let mut reader = stderr; + let mut buffer = Vec::new(); + reader.read_to_end(&mut buffer).map(|_| buffer) + }); + let deadline = Instant::now() + timeout; + + loop { + match child.try_wait() { + Ok(Some(status)) => { + let stdout = stdout_reader + .join() + .map_err(|_| "Failed to execute YouTube import process.".to_string())? + .map_err(|_| "Failed to execute YouTube import process.".to_string())?; + let stderr = stderr_reader + .join() + .map_err(|_| "Failed to execute YouTube import process.".to_string())? + .map_err(|_| "Failed to execute YouTube import process.".to_string())?; + return Ok(std::process::Output { + status, + stdout, + stderr, + }); + } + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err(timeout_message.to_string()); + } + thread::sleep(poll_interval); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err("Failed to execute YouTube import process.".to_string()); + } + } + } +} + +pub fn is_youtube_video_id(value: &str) -> bool { + value.len() == 11 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') +} + +pub fn project_payload_from_content(content: &str) -> Result { + if let Ok(parsed) = serde_json::from_str::(content) { + return Ok(parsed); + } + + let payload = serde_json::from_str::(content) + .map_err(|_| "Invalid project file format".to_string())?; + if let Some(sections) = payload.get("sections").and_then(Value::as_array) { + for (section_index, section) in sections.iter().enumerate() { + if section + .as_object() + .is_some_and(|section_object| !section_object.contains_key("timeRange")) + { + return Err(format!( + "Invalid project file format: sections[{section_index}].timeRange is required; reanalyze the project to restore section timing." + )); + } + } + } + + serde_json::from_value(payload).map_err(|_| "Invalid project file format".to_string()) +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScoreAttachmentPayload { + pub score_id: String, + pub file_name: String, + pub file_size_bytes: u64, +} + +/// Security Notes: project ids never come from free-form user input. They are +/// only ever minted by `next_project_id` as `project--`, so +/// anything from the WebView that does not match that exact shape is rejected +/// before it can influence a filesystem path (no separators, no `..`). +pub fn is_valid_project_id(value: &str) -> bool { + let Some(rest) = value.strip_prefix("project-") else { + return false; + }; + let mut segments = rest.split('-'); + match (segments.next(), segments.next(), segments.next()) { + (Some(timestamp), Some(counter), None) => { + !timestamp.is_empty() + && !counter.is_empty() + && timestamp.bytes().all(|byte| byte.is_ascii_digit()) + && counter.bytes().all(|byte| byte.is_ascii_digit()) + } + _ => false, + } +} + +/// Security Notes: score ids are minted locally via UUID v4 and must round-trip +/// as exactly a lowercase hyphenated UUID (8-4-4-4-12). This is an allowlist +/// check, so path traversal payloads (`..`, separators, null bytes) can never +/// reach the path join below. +pub fn is_valid_score_id(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() != 36 { + return false; + } + bytes.iter().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => *byte == b'-', + _ => matches!(byte, b'0'..=b'9' | b'a'..=b'f'), + }) +} + +/// Security Notes: the selected file is untrusted input (`User Input Boundary`). +/// We refuse symlinks before canonicalizing, require a real non-empty regular +/// file with a `.pdf` extension, cap the size at 25MB, and verify the `%PDF-` +/// magic bytes so a mislabeled file cannot be attached as a score. +pub fn validate_score_pdf_source(path: &Path) -> Result<(PathBuf, String, u64), String> { + let link_metadata = std::fs::symlink_metadata(path) + .map_err(|_| "Could not read the selected PDF file.".to_string())?; + if link_metadata.file_type().is_symlink() { + return Err("Could not read the selected PDF file.".to_string()); + } + + let canonical = path + .canonicalize() + .map_err(|_| "Could not read the selected PDF file.".to_string())?; + let extension = canonical + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .ok_or_else(|| "Choose a PDF file to attach as a score.".to_string())?; + if extension != "pdf" { + return Err("Choose a PDF file to attach as a score.".into()); + } + + let metadata = std::fs::metadata(&canonical) + .map_err(|_| "Could not read the selected PDF file.".to_string())?; + if !metadata.is_file() || metadata.len() == 0 { + return Err("Could not read the selected PDF file.".into()); + } + if metadata.len() > MAX_SCORE_PDF_BYTES { + return Err("Score PDF is too large (exceeds 25MB limit).".into()); + } + + let mut header = [0u8; PDF_MAGIC.len()]; + std::fs::File::open(&canonical) + .and_then(|mut file| file.read_exact(&mut header)) + .map_err(|_| "Could not read the selected PDF file.".to_string())?; + if header != PDF_MAGIC { + return Err("The selected file is not a valid PDF.".into()); + } + + let file_name = canonical + .file_name() + .and_then(|value| value.to_str()) + .map(|value| value.to_string()) + .ok_or_else(|| "Could not read the selected PDF file.".to_string())?; + + let file_size_bytes = metadata.len(); + Ok((canonical, file_name, file_size_bytes)) +} + +/// Security Notes: reads and deletes never accept an arbitrary path from the +/// WebView. The path is rebuilt server-side from validated ids, symlinks are +/// refused, and the canonicalized result must still live under the +/// canonicalized app-owned scores root (path-traversal guard). +pub fn resolve_existing_score_pdf(scores_root: &Path, score_id: &str) -> Result { + if !is_valid_score_id(score_id) { + return Err("Score was not found.".to_string()); + } + let candidate = scores_root.join(format!("{score_id}.pdf")); + let link_metadata = + std::fs::symlink_metadata(&candidate).map_err(|_| "Score was not found.".to_string())?; + if link_metadata.file_type().is_symlink() { + return Err("Score was not found.".to_string()); + } + + let canonical_root = scores_root + .canonicalize() + .map_err(|_| "Score was not found.".to_string())?; + let canonical = candidate + .canonicalize() + .map_err(|_| "Score was not found.".to_string())?; + if !canonical.starts_with(&canonical_root) { + return Err("Score was not found.".to_string()); + } + + let metadata = std::fs::metadata(&canonical).map_err(|_| "Score was not found.".to_string())?; + if !metadata.is_file() { + return Err("Score was not found.".to_string()); + } + Ok(canonical) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::io::Write; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_test_dir(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) + } + + fn shared_contract_payload(time_range: Value) -> Value { + json!({ + "id": "demo-song", + "title": "Late Night Set", + "sections": [ + { + "id": "verse-1", + "label": "verse", + "groove": "Straight eighths with a late snare feel", + "timeRange": time_range, + "confidence": { + "level": "medium", + "source": "model", + "notes": "Double-check the pickup into the chorus." + }, + "roles": [ + { + "id": "bass-guitar", + "name": "Bass Guitar", + "roleType": "instrument", + "harmony": { + "chord": "C#m7", + "functionLabel": "vi pedal anchor", + "source": "model" + }, + "cue": { + "kind": "transition", + "value": "Hold through the pickup before the downbeat." + }, + "range": { + "lowestNote": "C#2", + "highestNote": "E3" + }, + "confidence": { + "level": "medium", + "source": "model", + "notes": "Watch the slide into the turnaround." + }, + "rehearsalPriority": "high", + "simplification": "Stay on roots if the chorus entrance gets muddy.", + "setupNote": "Keep the attack short so the verse breathes.", + "manualOverrides": [], + "overlapWarnings": [ + "Density warning: competing with Keyboard Left Hand in low register." + ] + } + ], + "partGraph": [ + { + "role_id": "bass-guitar", + "is_active": true, + "handoff_to": ["lead-vocal"], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Start with the verse handoff and low-register overlap.", + "focusSections": ["verse-1"] + } + }) + } + + #[test] + fn rehearsal_song_payload_accepts_shared_section_contract() { + let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + + let parsed = serde_json::from_value::(payload) + .expect("shared rehearsal song contract should deserialize in Tauri"); + + assert_eq!(parsed.sections[0].id, "verse-1"); + } + + #[test] + fn rehearsal_song_payload_round_trips_score_attachments() { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["scoreAttachments"] = json!([ + { "id": "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", "fileName": "opener.pdf" } + ]); + + let parsed = serde_json::from_value::(payload) + .expect("song payload with score attachments should deserialize"); + let attachments = parsed + .score_attachments + .as_ref() + .expect("score attachments should survive deserialization"); + assert_eq!(attachments[0].file_name, "opener.pdf"); + + let serialized = + serde_json::to_value(&parsed).expect("song payload should serialize back to JSON"); + assert_eq!( + serialized["scoreAttachments"][0]["fileName"], + json!("opener.pdf") + ); + } + + #[test] + fn rehearsal_song_payload_accepts_legacy_files_without_score_attachments() { + let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + + let parsed = serde_json::from_value::(payload) + .expect("legacy payload without score attachments should deserialize"); + + assert!(parsed.score_attachments.is_none()); + let serialized = + serde_json::to_value(&parsed).expect("legacy payload should serialize back to JSON"); + assert!(serialized.get("scoreAttachments").is_none()); + } + + #[test] + fn rehearsal_song_payload_rejects_unknown_score_attachment_fields() { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["scoreAttachments"] = json!([ + { + "id": "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", + "fileName": "opener.pdf", + "sourcePath": "/etc/passwd" + } + ]); + + assert!(serde_json::from_value::(payload).is_err()); + } + + #[test] + fn rehearsal_song_payload_rejects_reversed_time_range() { + let payload = shared_contract_payload(json!({ "start": 30, "end": 10 })); + + assert!(serde_json::from_value::(payload).is_err()); + } + + #[test] + fn project_payload_from_content_rejects_legacy_missing_time_range() { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["sections"][0] + .as_object_mut() + .expect("section should be an object") + .remove("timeRange"); + let content = serde_json::to_string(&payload).expect("legacy payload should serialize"); + + let error = project_payload_from_content(&content) + .expect_err("legacy sections without timing should fail closed"); + + assert!(error.contains("timeRange")); + } + + #[test] + fn youtube_url_validation_requires_exact_video_ids() { + assert!(is_supported_youtube_url( + "https://youtube.com/watch?v=abc123DEF45" + )); + assert!(is_supported_youtube_url( + "https://www.youtube.com/watch?v=abc123DEF45" + )); + assert!(is_supported_youtube_url("https://youtu.be/abc123DEF45")); + + assert!(!is_supported_youtube_url( + "https://evil.youtube.com/watch?v=abc123DEF45" + )); + assert!(!is_supported_youtube_url( + "https://youtube.com/watch?v=abc123" + )); + assert!(!is_supported_youtube_url( + "https://youtube.com/watch?v=abc123DEF4!" + )); + assert!(!is_supported_youtube_url("https://youtube.com/watch")); + assert!(!is_supported_youtube_url( + "https://youtube.com/watch?v=abc123DEF45&v=def456GHI78" + )); + assert!(!is_supported_youtube_url("https://youtu.be/abc123")); + assert!(!is_supported_youtube_url("https://youtu.be/abc123DEF4!")); + } + + #[test] + fn youtube_missing_metadata_error_does_not_expose_payload() { + let parsed = json!({ + "ok": true, + "filepath": "/Users/someone/private-song.m4a", + "metadata": null + }); + + let message = youtube_missing_metadata_error(&parsed); + + assert_eq!(message, "YouTube import reported ok but missing metadata."); + assert!(!message.contains("private-song")); + assert!(!message.contains("filepath")); + } + + #[test] + fn youtube_process_timeout_kills_and_reaps_child() { + if std::env::var_os("BANDSCOPE_TEST_CHILD_SLEEP").is_some() { + thread::sleep(Duration::from_secs(5)); + return; + } + + let current_test_binary = std::env::current_exe().expect("test binary should resolve"); + let mut command = Command::new(current_test_binary); + command + .env("BANDSCOPE_TEST_CHILD_SLEEP", "1") + .arg("--exact") + .arg("tests::youtube_process_timeout_kills_and_reaps_child") + .arg("--nocapture"); + + let result = wait_for_process_output( + command, + Duration::from_millis(50), + Duration::from_millis(5), + "YouTube import timed out.", + ); + + assert_eq!( + result.expect_err("slow child should time out"), + "YouTube import timed out." + ); + } + + #[test] + fn youtube_process_output_drains_large_stdout_and_stderr_before_exit() { + if std::env::var_os("BANDSCOPE_TEST_CHILD_LARGE_OUTPUT").is_some() { + let chunk = vec![b'x'; 1024 * 1024]; + std::io::stdout() + .write_all(&chunk) + .expect("child stdout should accept test bytes"); + std::io::stderr() + .write_all(&chunk) + .expect("child stderr should accept test bytes"); + return; + } + + let current_test_binary = std::env::current_exe().expect("test binary should resolve"); + let mut command = Command::new(current_test_binary); + command + .env("BANDSCOPE_TEST_CHILD_LARGE_OUTPUT", "1") + .arg("--exact") + .arg("tests::youtube_process_output_drains_large_stdout_and_stderr_before_exit") + .arg("--nocapture"); + + let output = wait_for_process_output( + command, + Duration::from_secs(2), + Duration::from_millis(5), + "YouTube import timed out.", + ) + .expect("large child output should be drained before timeout"); + + assert!(output.status.success()); + assert!(output.stdout.len() >= 1024 * 1024); + assert!(output.stderr.len() >= 1024 * 1024); + } + + #[test] + fn youtube_metadata_must_reference_supported_audio_inside_cache_root() { + let cache_root = unique_test_dir("youtube-cache"); + let outside_root = unique_test_dir("youtube-outside"); + std::fs::create_dir_all(&cache_root).expect("cache root should be created"); + std::fs::create_dir_all(&outside_root).expect("outside root should be created"); + + let inside_file = cache_root.join("downloaded.m4a"); + let empty_file = cache_root.join("empty.m4a"); + let unsupported_file = cache_root.join("downloaded.txt"); + let outside_file = outside_root.join("downloaded.m4a"); + std::fs::write(&inside_file, b"audio").expect("inside file should be written"); + std::fs::write(&empty_file, b"").expect("empty file should be written"); + std::fs::write(&unsupported_file, b"not audio") + .expect("unsupported file should be written"); + std::fs::write(&outside_file, b"audio").expect("outside file should be written"); + + let accepted = youtube_source_from_metadata( + &json!({ "filepath": inside_file, "title": "Live/Test" }), + &cache_root, + ) + .expect("in-cache supported audio should be accepted"); + assert_eq!(accepted.extension, "m4a"); + assert_eq!(accepted.file_name, "Live_Test.m4a"); + + assert!(youtube_source_from_metadata( + &json!({ "filepath": empty_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + assert!(youtube_source_from_metadata( + &json!({ "filepath": unsupported_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + assert!(youtube_source_from_metadata( + &json!({ "filepath": outside_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + + #[cfg(unix)] + { + let symlink_file = cache_root.join("linked.m4a"); + std::os::unix::fs::symlink(&inside_file, &symlink_file) + .expect("symlink should be created"); + assert!(youtube_source_from_metadata( + &json!({ "filepath": symlink_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + } + + let _ = std::fs::remove_dir_all(cache_root); + let _ = std::fs::remove_dir_all(outside_root); + } + + #[test] + fn project_id_guard_accepts_generated_ids_only() { + let generated = next_project_id(&AppState::default()); + assert!(is_valid_project_id(&generated)); + assert!(is_valid_project_id("project-1751234567890123456-1")); + + assert!(!is_valid_project_id("")); + assert!(!is_valid_project_id("project-")); + assert!(!is_valid_project_id("project-123")); + assert!(!is_valid_project_id("project-123-")); + assert!(!is_valid_project_id("project-123-4-5")); + assert!(!is_valid_project_id("project-abc-1")); + assert!(!is_valid_project_id("project-123-1x")); + assert!(!is_valid_project_id("other-123-1")); + assert!(!is_valid_project_id("../project-123-1")); + assert!(!is_valid_project_id("project-123-1/..")); + assert!(!is_valid_project_id("project-..-1")); + assert!(!is_valid_project_id("project-123-1/escape")); + } + + #[test] + fn score_id_guard_accepts_lowercase_uuid_v4_only() { + let generated = uuid::Uuid::new_v4().to_string(); + assert!(is_valid_score_id(&generated)); + assert!(is_valid_score_id("6fa459ea-ee8a-3ca4-894e-db77e160355e")); + + assert!(!is_valid_score_id("")); + assert!(!is_valid_score_id("not-a-uuid")); + assert!(!is_valid_score_id("6FA459EA-EE8A-3CA4-894E-DB77E160355E")); + assert!(!is_valid_score_id("6fa459eaee8a3ca4894edb77e160355e")); + assert!(!is_valid_score_id("{6fa459ea-ee8a-3ca4-894e-db77e160355e}")); + assert!(!is_valid_score_id("../../../../etc/passwd-aaaa-bbbb-cc")); + assert!(!is_valid_score_id( + "6fa459ea-ee8a-3ca4-894e-db77e160355e/.." + )); + assert!(!is_valid_score_id("6fa459ea-ee8a-3ca4-894e-db77e16035/e")); + } + + #[test] + fn score_pdf_source_requires_pdf_magic_size_and_real_file() { + let root = unique_test_dir("score-source"); + std::fs::create_dir_all(&root).expect("score source root should be created"); + + let valid = root.join("score.pdf"); + std::fs::write(&valid, b"%PDF-1.7 fake body").expect("valid pdf should be written"); + let (canonical, file_name, size) = + validate_score_pdf_source(&valid).expect("valid pdf should be accepted"); + assert_eq!(file_name, "score.pdf"); + assert_eq!(size, 18); + assert!(canonical.ends_with("score.pdf")); + + let wrong_magic = root.join("not-really.pdf"); + std::fs::write(&wrong_magic, b"PK\x03\x04 zip bytes") + .expect("wrong magic file should be written"); + assert!(validate_score_pdf_source(&wrong_magic).is_err()); + + let short = root.join("short.pdf"); + std::fs::write(&short, b"%PD").expect("short file should be written"); + assert!(validate_score_pdf_source(&short).is_err()); + + let empty = root.join("empty.pdf"); + std::fs::write(&empty, b"").expect("empty file should be written"); + assert!(validate_score_pdf_source(&empty).is_err()); + + let wrong_extension = root.join("score.txt"); + std::fs::write(&wrong_extension, b"%PDF-1.7").expect("txt file should be written"); + assert!(validate_score_pdf_source(&wrong_extension).is_err()); + + let missing = root.join("missing.pdf"); + assert!(validate_score_pdf_source(&missing).is_err()); + + let oversized = root.join("oversized.pdf"); + { + let file = std::fs::File::create(&oversized).expect("oversized file should be created"); + let mut file = file; + file.write_all(b"%PDF-1.7") + .expect("oversized header should be written"); + file.set_len(MAX_SCORE_PDF_BYTES + 1) + .expect("oversized file should be extended"); + } + assert!(validate_score_pdf_source(&oversized).is_err()); + + #[cfg(unix)] + { + let symlinked = root.join("linked.pdf"); + std::os::unix::fs::symlink(&valid, &symlinked).expect("symlink should be created"); + assert!(validate_score_pdf_source(&symlinked).is_err()); + } + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn score_pdf_resolution_rejects_traversal_and_escapes() { + let scores_root = unique_test_dir("score-resolve"); + let outside_root = unique_test_dir("score-outside"); + std::fs::create_dir_all(&scores_root).expect("scores root should be created"); + std::fs::create_dir_all(&outside_root).expect("outside root should be created"); + + let score_id = "6fa459ea-ee8a-3ca4-894e-db77e160355e"; + let inside_file = scores_root.join(format!("{score_id}.pdf")); + std::fs::write(&inside_file, b"%PDF-1.7").expect("inside file should be written"); + + let resolved = resolve_existing_score_pdf(&scores_root, score_id) + .expect("stored score inside the root should resolve"); + assert!(resolved.ends_with(format!("{score_id}.pdf"))); + + assert!(resolve_existing_score_pdf(&scores_root, "../escape").is_err()); + assert!(resolve_existing_score_pdf(&scores_root, "..").is_err()); + assert!( + resolve_existing_score_pdf(&scores_root, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + .is_err() + ); + + #[cfg(unix)] + { + let outside_file = outside_root.join("secret.pdf"); + std::fs::write(&outside_file, b"%PDF-1.7").expect("outside file should be written"); + let linked_id = "11111111-2222-3333-4444-555555555555"; + std::os::unix::fs::symlink(&outside_file, scores_root.join(format!("{linked_id}.pdf"))) + .expect("symlink should be created"); + assert!(resolve_existing_score_pdf(&scores_root, linked_id).is_err()); + } + + let _ = std::fs::remove_dir_all(scores_root); + let _ = std::fs::remove_dir_all(outside_root); + } +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e8b42e09..1bc32019 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,6 +18,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.20.0", + "pdfjs-dist": "6.1.200", "react": "^19.2.4", "react-dom": "^19.2.7", "tailwind-merge": "^3.6.0", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 0df254ea..cf90c103 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -71,6 +71,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" name = "bandscope-desktop" version = "0.1.0" dependencies = [ + "bandscope-desktop-core", "rfd", "serde", "serde_json", @@ -79,6 +80,17 @@ dependencies = [ "time", "tokio", "url", + "uuid", +] + +[[package]] +name = "bandscope-desktop-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "time", + "url", ] [[package]] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index de282982..1b25fed4 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" tauri-build = { version = "2", default-features = false, features = [] } [dependencies] +bandscope-desktop-core = { path = "../core" } rfd = "0.17.2" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -14,6 +15,7 @@ tauri = { version = "2.11.1", default-features = false, features = ["wry"] } time = { version = "0.3", features = ["formatting", "macros"] } tokio = { version = "1.50.0", features = ["time"] } url = "2.5.8" +uuid = { version = "1", features = ["v4"] } [features] default = [] diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 997eeba3..0caf74c6 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -4,6 +4,12 @@ fn main() { "start_analysis_job", "get_analysis_job_status", "select_local_audio_source", + "import_youtube_url", + "save_project", + "load_project", + "attach_score_pdf", + "read_score_pdf", + "remove_score_pdf", ]), )) .expect("failed to build tauri application manifest"); diff --git a/apps/desktop/src-tauri/capabilities/main.json b/apps/desktop/src-tauri/capabilities/main.json index 351eb9c0..8103420f 100644 --- a/apps/desktop/src-tauri/capabilities/main.json +++ b/apps/desktop/src-tauri/capabilities/main.json @@ -8,6 +8,12 @@ "core:event:allow-unlisten", "allow-start-analysis-job", "allow-get-analysis-job-status", - "allow-select-local-audio-source" + "allow-select-local-audio-source", + "allow-import-youtube-url", + "allow-save-project", + "allow-load-project", + "allow-attach-score-pdf", + "allow-read-score-pdf", + "allow-remove-score-pdf" ] } diff --git a/apps/desktop/src-tauri/gen/schemas/acl-manifests.json b/apps/desktop/src-tauri/gen/schemas/acl-manifests.json index 18685a13..c5cda5d6 100644 --- a/apps/desktop/src-tauri/gen/schemas/acl-manifests.json +++ b/apps/desktop/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-get-analysis-job-status":{"identifier":"allow-get-analysis-job-status","description":"Enables the get_analysis_job_status command without any pre-configured scope.","commands":{"allow":["get_analysis_job_status"],"deny":[]}},"allow-select-local-audio-source":{"identifier":"allow-select-local-audio-source","description":"Enables the select_local_audio_source command without any pre-configured scope.","commands":{"allow":["select_local_audio_source"],"deny":[]}},"allow-start-analysis-job":{"identifier":"allow-start-analysis-job","description":"Enables the start_analysis_job command without any pre-configured scope.","commands":{"allow":["start_analysis_job"],"deny":[]}},"deny-get-analysis-job-status":{"identifier":"deny-get-analysis-job-status","description":"Denies the get_analysis_job_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_analysis_job_status"]}},"deny-select-local-audio-source":{"identifier":"deny-select-local-audio-source","description":"Denies the select_local_audio_source command without any pre-configured scope.","commands":{"allow":[],"deny":["select_local_audio_source"]}},"deny-start-analysis-job":{"identifier":"deny-start-analysis-job","description":"Denies the start_analysis_job command without any pre-configured scope.","commands":{"allow":[],"deny":["start_analysis_job"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-attach-score-pdf":{"identifier":"allow-attach-score-pdf","description":"Enables the attach_score_pdf command without any pre-configured scope.","commands":{"allow":["attach_score_pdf"],"deny":[]}},"allow-get-analysis-job-status":{"identifier":"allow-get-analysis-job-status","description":"Enables the get_analysis_job_status command without any pre-configured scope.","commands":{"allow":["get_analysis_job_status"],"deny":[]}},"allow-import-youtube-url":{"identifier":"allow-import-youtube-url","description":"Enables the import_youtube_url command without any pre-configured scope.","commands":{"allow":["import_youtube_url"],"deny":[]}},"allow-load-project":{"identifier":"allow-load-project","description":"Enables the load_project command without any pre-configured scope.","commands":{"allow":["load_project"],"deny":[]}},"allow-read-score-pdf":{"identifier":"allow-read-score-pdf","description":"Enables the read_score_pdf command without any pre-configured scope.","commands":{"allow":["read_score_pdf"],"deny":[]}},"allow-remove-score-pdf":{"identifier":"allow-remove-score-pdf","description":"Enables the remove_score_pdf command without any pre-configured scope.","commands":{"allow":["remove_score_pdf"],"deny":[]}},"allow-save-project":{"identifier":"allow-save-project","description":"Enables the save_project command without any pre-configured scope.","commands":{"allow":["save_project"],"deny":[]}},"allow-select-local-audio-source":{"identifier":"allow-select-local-audio-source","description":"Enables the select_local_audio_source command without any pre-configured scope.","commands":{"allow":["select_local_audio_source"],"deny":[]}},"allow-start-analysis-job":{"identifier":"allow-start-analysis-job","description":"Enables the start_analysis_job command without any pre-configured scope.","commands":{"allow":["start_analysis_job"],"deny":[]}},"deny-attach-score-pdf":{"identifier":"deny-attach-score-pdf","description":"Denies the attach_score_pdf command without any pre-configured scope.","commands":{"allow":[],"deny":["attach_score_pdf"]}},"deny-get-analysis-job-status":{"identifier":"deny-get-analysis-job-status","description":"Denies the get_analysis_job_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_analysis_job_status"]}},"deny-import-youtube-url":{"identifier":"deny-import-youtube-url","description":"Denies the import_youtube_url command without any pre-configured scope.","commands":{"allow":[],"deny":["import_youtube_url"]}},"deny-load-project":{"identifier":"deny-load-project","description":"Denies the load_project command without any pre-configured scope.","commands":{"allow":[],"deny":["load_project"]}},"deny-read-score-pdf":{"identifier":"deny-read-score-pdf","description":"Denies the read_score_pdf command without any pre-configured scope.","commands":{"allow":[],"deny":["read_score_pdf"]}},"deny-remove-score-pdf":{"identifier":"deny-remove-score-pdf","description":"Denies the remove_score_pdf command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_score_pdf"]}},"deny-save-project":{"identifier":"deny-save-project","description":"Denies the save_project command without any pre-configured scope.","commands":{"allow":[],"deny":["save_project"]}},"deny-select-local-audio-source":{"identifier":"deny-select-local-audio-source","description":"Denies the select_local_audio_source command without any pre-configured scope.","commands":{"allow":[],"deny":["select_local_audio_source"]}},"deny-start-analysis-job":{"identifier":"deny-start-analysis-job","description":"Denies the start_analysis_job command without any pre-configured scope.","commands":{"allow":[],"deny":["start_analysis_job"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/apps/desktop/src-tauri/gen/schemas/capabilities.json b/apps/desktop/src-tauri/gen/schemas/capabilities.json index 8a1f651e..25d76424 100644 --- a/apps/desktop/src-tauri/gen/schemas/capabilities.json +++ b/apps/desktop/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"main-capability":{"identifier":"main-capability","description":"Capability for the main BandScope window to use the analysis orchestration commands.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","allow-start-analysis-job","allow-get-analysis-job-status","allow-select-local-audio-source"]}} \ No newline at end of file +{"main-capability":{"identifier":"main-capability","description":"Capability for the main BandScope window to use the analysis orchestration commands.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","allow-start-analysis-job","allow-get-analysis-job-status","allow-select-local-audio-source","allow-import-youtube-url","allow-save-project","allow-load-project","allow-attach-score-pdf","allow-read-score-pdf","allow-remove-score-pdf"]}} \ No newline at end of file diff --git a/apps/desktop/src-tauri/gen/schemas/desktop-schema.json b/apps/desktop/src-tauri/gen/schemas/desktop-schema.json index 1c57ee00..a46e367a 100644 --- a/apps/desktop/src-tauri/gen/schemas/desktop-schema.json +++ b/apps/desktop/src-tauri/gen/schemas/desktop-schema.json @@ -176,12 +176,48 @@ "Identifier": { "description": "Permission identifier", "oneOf": [ + { + "description": "Enables the attach_score_pdf command without any pre-configured scope.", + "type": "string", + "const": "allow-attach-score-pdf", + "markdownDescription": "Enables the attach_score_pdf command without any pre-configured scope." + }, { "description": "Enables the get_analysis_job_status command without any pre-configured scope.", "type": "string", "const": "allow-get-analysis-job-status", "markdownDescription": "Enables the get_analysis_job_status command without any pre-configured scope." }, + { + "description": "Enables the import_youtube_url command without any pre-configured scope.", + "type": "string", + "const": "allow-import-youtube-url", + "markdownDescription": "Enables the import_youtube_url command without any pre-configured scope." + }, + { + "description": "Enables the load_project command without any pre-configured scope.", + "type": "string", + "const": "allow-load-project", + "markdownDescription": "Enables the load_project command without any pre-configured scope." + }, + { + "description": "Enables the read_score_pdf command without any pre-configured scope.", + "type": "string", + "const": "allow-read-score-pdf", + "markdownDescription": "Enables the read_score_pdf command without any pre-configured scope." + }, + { + "description": "Enables the remove_score_pdf command without any pre-configured scope.", + "type": "string", + "const": "allow-remove-score-pdf", + "markdownDescription": "Enables the remove_score_pdf command without any pre-configured scope." + }, + { + "description": "Enables the save_project command without any pre-configured scope.", + "type": "string", + "const": "allow-save-project", + "markdownDescription": "Enables the save_project command without any pre-configured scope." + }, { "description": "Enables the select_local_audio_source command without any pre-configured scope.", "type": "string", @@ -194,12 +230,48 @@ "const": "allow-start-analysis-job", "markdownDescription": "Enables the start_analysis_job command without any pre-configured scope." }, + { + "description": "Denies the attach_score_pdf command without any pre-configured scope.", + "type": "string", + "const": "deny-attach-score-pdf", + "markdownDescription": "Denies the attach_score_pdf command without any pre-configured scope." + }, { "description": "Denies the get_analysis_job_status command without any pre-configured scope.", "type": "string", "const": "deny-get-analysis-job-status", "markdownDescription": "Denies the get_analysis_job_status command without any pre-configured scope." }, + { + "description": "Denies the import_youtube_url command without any pre-configured scope.", + "type": "string", + "const": "deny-import-youtube-url", + "markdownDescription": "Denies the import_youtube_url command without any pre-configured scope." + }, + { + "description": "Denies the load_project command without any pre-configured scope.", + "type": "string", + "const": "deny-load-project", + "markdownDescription": "Denies the load_project command without any pre-configured scope." + }, + { + "description": "Denies the read_score_pdf command without any pre-configured scope.", + "type": "string", + "const": "deny-read-score-pdf", + "markdownDescription": "Denies the read_score_pdf command without any pre-configured scope." + }, + { + "description": "Denies the remove_score_pdf command without any pre-configured scope.", + "type": "string", + "const": "deny-remove-score-pdf", + "markdownDescription": "Denies the remove_score_pdf command without any pre-configured scope." + }, + { + "description": "Denies the save_project command without any pre-configured scope.", + "type": "string", + "const": "deny-save-project", + "markdownDescription": "Denies the save_project command without any pre-configured scope." + }, { "description": "Denies the select_local_audio_source command without any pre-configured scope.", "type": "string", diff --git a/apps/desktop/src-tauri/gen/schemas/macOS-schema.json b/apps/desktop/src-tauri/gen/schemas/macOS-schema.json index 1c57ee00..a46e367a 100644 --- a/apps/desktop/src-tauri/gen/schemas/macOS-schema.json +++ b/apps/desktop/src-tauri/gen/schemas/macOS-schema.json @@ -176,12 +176,48 @@ "Identifier": { "description": "Permission identifier", "oneOf": [ + { + "description": "Enables the attach_score_pdf command without any pre-configured scope.", + "type": "string", + "const": "allow-attach-score-pdf", + "markdownDescription": "Enables the attach_score_pdf command without any pre-configured scope." + }, { "description": "Enables the get_analysis_job_status command without any pre-configured scope.", "type": "string", "const": "allow-get-analysis-job-status", "markdownDescription": "Enables the get_analysis_job_status command without any pre-configured scope." }, + { + "description": "Enables the import_youtube_url command without any pre-configured scope.", + "type": "string", + "const": "allow-import-youtube-url", + "markdownDescription": "Enables the import_youtube_url command without any pre-configured scope." + }, + { + "description": "Enables the load_project command without any pre-configured scope.", + "type": "string", + "const": "allow-load-project", + "markdownDescription": "Enables the load_project command without any pre-configured scope." + }, + { + "description": "Enables the read_score_pdf command without any pre-configured scope.", + "type": "string", + "const": "allow-read-score-pdf", + "markdownDescription": "Enables the read_score_pdf command without any pre-configured scope." + }, + { + "description": "Enables the remove_score_pdf command without any pre-configured scope.", + "type": "string", + "const": "allow-remove-score-pdf", + "markdownDescription": "Enables the remove_score_pdf command without any pre-configured scope." + }, + { + "description": "Enables the save_project command without any pre-configured scope.", + "type": "string", + "const": "allow-save-project", + "markdownDescription": "Enables the save_project command without any pre-configured scope." + }, { "description": "Enables the select_local_audio_source command without any pre-configured scope.", "type": "string", @@ -194,12 +230,48 @@ "const": "allow-start-analysis-job", "markdownDescription": "Enables the start_analysis_job command without any pre-configured scope." }, + { + "description": "Denies the attach_score_pdf command without any pre-configured scope.", + "type": "string", + "const": "deny-attach-score-pdf", + "markdownDescription": "Denies the attach_score_pdf command without any pre-configured scope." + }, { "description": "Denies the get_analysis_job_status command without any pre-configured scope.", "type": "string", "const": "deny-get-analysis-job-status", "markdownDescription": "Denies the get_analysis_job_status command without any pre-configured scope." }, + { + "description": "Denies the import_youtube_url command without any pre-configured scope.", + "type": "string", + "const": "deny-import-youtube-url", + "markdownDescription": "Denies the import_youtube_url command without any pre-configured scope." + }, + { + "description": "Denies the load_project command without any pre-configured scope.", + "type": "string", + "const": "deny-load-project", + "markdownDescription": "Denies the load_project command without any pre-configured scope." + }, + { + "description": "Denies the read_score_pdf command without any pre-configured scope.", + "type": "string", + "const": "deny-read-score-pdf", + "markdownDescription": "Denies the read_score_pdf command without any pre-configured scope." + }, + { + "description": "Denies the remove_score_pdf command without any pre-configured scope.", + "type": "string", + "const": "deny-remove-score-pdf", + "markdownDescription": "Denies the remove_score_pdf command without any pre-configured scope." + }, + { + "description": "Denies the save_project command without any pre-configured scope.", + "type": "string", + "const": "deny-save-project", + "markdownDescription": "Denies the save_project command without any pre-configured scope." + }, { "description": "Denies the select_local_audio_source command without any pre-configured scope.", "type": "string", diff --git a/apps/desktop/src-tauri/permissions/autogenerated/attach_score_pdf.toml b/apps/desktop/src-tauri/permissions/autogenerated/attach_score_pdf.toml new file mode 100644 index 00000000..c99126a9 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/attach_score_pdf.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-attach-score-pdf" +description = "Enables the attach_score_pdf command without any pre-configured scope." +commands.allow = ["attach_score_pdf"] + +[[permission]] +identifier = "deny-attach-score-pdf" +description = "Denies the attach_score_pdf command without any pre-configured scope." +commands.deny = ["attach_score_pdf"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/import_youtube_url.toml b/apps/desktop/src-tauri/permissions/autogenerated/import_youtube_url.toml new file mode 100644 index 00000000..03debda8 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/import_youtube_url.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-import-youtube-url" +description = "Enables the import_youtube_url command without any pre-configured scope." +commands.allow = ["import_youtube_url"] + +[[permission]] +identifier = "deny-import-youtube-url" +description = "Denies the import_youtube_url command without any pre-configured scope." +commands.deny = ["import_youtube_url"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/load_project.toml b/apps/desktop/src-tauri/permissions/autogenerated/load_project.toml new file mode 100644 index 00000000..40a6ae52 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/load_project.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-load-project" +description = "Enables the load_project command without any pre-configured scope." +commands.allow = ["load_project"] + +[[permission]] +identifier = "deny-load-project" +description = "Denies the load_project command without any pre-configured scope." +commands.deny = ["load_project"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/read_score_pdf.toml b/apps/desktop/src-tauri/permissions/autogenerated/read_score_pdf.toml new file mode 100644 index 00000000..b819abe9 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/read_score_pdf.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-read-score-pdf" +description = "Enables the read_score_pdf command without any pre-configured scope." +commands.allow = ["read_score_pdf"] + +[[permission]] +identifier = "deny-read-score-pdf" +description = "Denies the read_score_pdf command without any pre-configured scope." +commands.deny = ["read_score_pdf"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/remove_score_pdf.toml b/apps/desktop/src-tauri/permissions/autogenerated/remove_score_pdf.toml new file mode 100644 index 00000000..8133491d --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/remove_score_pdf.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-remove-score-pdf" +description = "Enables the remove_score_pdf command without any pre-configured scope." +commands.allow = ["remove_score_pdf"] + +[[permission]] +identifier = "deny-remove-score-pdf" +description = "Denies the remove_score_pdf command without any pre-configured scope." +commands.deny = ["remove_score_pdf"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/save_project.toml b/apps/desktop/src-tauri/permissions/autogenerated/save_project.toml new file mode 100644 index 00000000..3832c067 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/save_project.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-save-project" +description = "Enables the save_project command without any pre-configured scope." +commands.allow = ["save_project"] + +[[permission]] +identifier = "deny-save-project" +description = "Denies the save_project command without any pre-configured scope." +commands.deny = ["save_project"] diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 2de7adde..c3e6cec6 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,274 +1,19 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use bandscope_desktop_core::*; use rfd::FileDialog; -use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{json, Value}; use std::{ - collections::HashMap, io::{BufRead, BufReader, Read, Write}, path::{Path, PathBuf}, process::{Command, Stdio}, - sync::{ - atomic::{AtomicU64, AtomicUsize, Ordering}, - mpsc, Arc, Mutex, - }, + sync::{atomic::Ordering, mpsc}, thread, - time::{Duration, Instant}, + time::Instant, }; use tauri::{Emitter, Manager, Runtime}; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; -#[derive(Clone)] -struct AppState(Arc); - -struct AppStateInner { - next_job: AtomicU64, - in_flight_jobs: AtomicUsize, - jobs: Mutex>, - bootstrap_sources: Mutex>, -} - -const MAX_IN_FLIGHT_JOBS: usize = 2; -const ANALYSIS_PROCESS_TIMEOUT: Duration = Duration::from_secs(30); -const ANALYSIS_WAIT_POLL: Duration = Duration::from_millis(50); -const AUDIO_EXTENSIONS: [&str; 4] = ["wav", "mp3", "flac", "m4a"]; -const MISSING_ANALYSIS_PYTHON: &str = "__bandscope_missing_analysis_python__"; -const YOUTUBE_IMPORT_TIMEOUT: Duration = Duration::from_secs(120); - -impl Default for AppState { - fn default() -> Self { - Self(Arc::new(AppStateInner { - next_job: AtomicU64::new(1), - in_flight_jobs: AtomicUsize::new(0), - jobs: Mutex::new(HashMap::new()), - bootstrap_sources: Mutex::new(HashMap::new()), - })) - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct AnalysisJobRequest { - source_kind: String, - project_id: Option, - source_label: String, - role_focus: Vec, - local_source: Option, - cache_root: Option, - temp_root: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum AnalysisJobErrorCode { - InvalidRequest, - NotFound, - EngineUnavailable, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct AnalysisJobError { - code: AnalysisJobErrorCode, - message: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum AnalysisJobState { - Queued, - Running, - Succeeded, - Failed, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum AnalysisJobStage { - Queued, - Decode, - Separate, - Analyze, - Persist, - Ready, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -enum AnalysisCacheStatus { - Disabled, - Miss, - Hit, - Stored, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RehearsalSongPayload { - id: String, - title: String, - sections: Vec, - export_summary: ExportSummaryPayload, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ConfidencePayload { - level: String, - source: String, - notes: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct CuePayload { - kind: String, - value: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RangePayload { - lowest_note: String, - highest_note: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct HarmonyPayload { - chord: String, - function_label: String, - source: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ManualOverridePayload { - field: String, - value: HarmonyPayload, - source: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RehearsalRolePayload { - id: String, - name: String, - role_type: String, - harmony: HarmonyPayload, - cue: CuePayload, - range: RangePayload, - confidence: ConfidencePayload, - rehearsal_priority: String, - simplification: String, - setup_note: String, - manual_overrides: Vec, - overlap_warnings: Vec, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct SectionTimeRangePayload { - start: u32, - end: u32, -} - -impl<'de> Deserialize<'de> for SectionTimeRangePayload { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(rename_all = "camelCase", deny_unknown_fields)] - struct RawSectionTimeRangePayload { - start: u32, - end: u32, - } - - let raw = RawSectionTimeRangePayload::deserialize(deserializer)?; - if raw.end <= raw.start { - return Err(serde::de::Error::custom( - "section timeRange end must be greater than start", - )); - } - - Ok(Self { - start: raw.start, - end: raw.end, - }) - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct PartGraphNodePayload { - role_id: String, - is_active: bool, - handoff_to: Vec, - handoff_from: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RehearsalSectionPayload { - id: String, - label: String, - groove: String, - time_range: SectionTimeRangePayload, - confidence: ConfidencePayload, - roles: Vec, - part_graph: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ExportSummaryPayload { - format: String, - headline: String, - focus_sections: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct AnalysisJobStatus { - job_id: String, - state: AnalysisJobState, - requested_at: String, - updated_at: String, - #[serde(skip_serializing_if = "Option::is_none")] - progress_label: Option, - #[serde(skip_serializing_if = "Option::is_none")] - progress_stage: Option, - #[serde(skip_serializing_if = "Option::is_none")] - progress_percent: Option, - #[serde(skip_serializing_if = "Option::is_none")] - cache_status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct LocalAudioSourcePayload { - source_path: String, - file_name: String, - extension: String, - file_size_bytes: u64, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ProjectBootstrapSummaryPayload { - project_id: String, - source_mode: String, - project_root: String, - cache_root: String, - temp_root: String, - source: LocalAudioSourcePayload, -} - fn iso_timestamp_now() -> String { OffsetDateTime::now_utc() .format(&Rfc3339) @@ -365,14 +110,6 @@ fn release_job_slot(state: &AppState) { state.0.in_flight_jobs.fetch_sub(1, Ordering::SeqCst); } -fn next_project_id(state: &AppState) -> String { - format!( - "project-{}-{}", - OffsetDateTime::now_utc().unix_timestamp_nanos(), - state.0.next_job.fetch_add(1, Ordering::Relaxed) - ) -} - fn app_owned_root( app: &tauri::AppHandle, kind: &str, @@ -430,74 +167,6 @@ fn normalize_local_audio_source(path: &Path) -> Result Result { - let filepath = metadata - .get("filepath") - .and_then(|value| value.as_str()) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| "Failed to parse YouTube import response.".to_string())?; - let title = metadata - .get("title") - .and_then(|value| value.as_str()) - .unwrap_or("Unknown YouTube Audio"); - let path = Path::new(filepath); - let link_metadata = std::fs::symlink_metadata(path) - .map_err(|_| "Could not read downloaded audio file.".to_string())?; - if link_metadata.file_type().is_symlink() { - return Err("YouTube import returned an invalid audio path.".to_string()); - } - - let canonical_cache_root = cache_root - .canonicalize() - .map_err(|_| "Could not validate YouTube import workspace.".to_string())?; - let canonical = path - .canonicalize() - .map_err(|_| "Could not read downloaded audio file.".to_string())?; - if !canonical.starts_with(&canonical_cache_root) { - return Err("YouTube import returned an invalid audio path.".to_string()); - } - - let file_metadata = std::fs::metadata(&canonical) - .map_err(|_| "Could not read downloaded audio file.".to_string())?; - if !file_metadata.is_file() || file_metadata.len() == 0 { - return Err("YouTube import returned an invalid audio file.".to_string()); - } - - let extension = canonical - .extension() - .and_then(|value| value.to_str()) - .map(|value| value.to_ascii_lowercase()) - .ok_or_else(|| "YouTube import returned an unsupported audio format.".to_string())?; - if !AUDIO_EXTENSIONS.contains(&extension.as_str()) { - return Err("YouTube import returned an unsupported audio format.".to_string()); - } - - let safe_title: String = title - .chars() - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '.' => '_', - c if c.is_control() => '_', - c => c, - }) - .take(100) - .collect(); - let safe_title = if safe_title.is_empty() { - "youtube_audio".to_string() - } else { - safe_title - }; - - Ok(LocalAudioSourcePayload { - source_path: canonical.to_string_lossy().into_owned(), - file_name: format!("{safe_title}.{extension}"), - extension, - file_size_bytes: file_metadata.len(), - }) -} - fn parse_request_payload(payload: Value) -> Result { let Value::Object(map) = payload else { return Err("Invalid analysis job request: invalid field 'root'".into()); @@ -1056,150 +725,6 @@ async fn import_youtube_url( Err("YouTube import failed with an unknown error.".to_string()) } -fn is_supported_youtube_url(url: &str) -> bool { - let parsed_url = match url::Url::parse(url) { - Ok(u) => u, - Err(_) => return false, - }; - if parsed_url.scheme() != "https" { - return false; - } - - let host = parsed_url.host_str().unwrap_or("").to_lowercase(); - if host == "youtu.be" { - let mut segments = match parsed_url.path_segments() { - Some(s) => s.filter(|segment| !segment.is_empty()), - None => return false, - }; - let Some(video_id) = segments.next() else { - return false; - }; - return is_youtube_video_id(video_id) && segments.next().is_none(); - } - - if host == "youtube.com" || host == "www.youtube.com" { - if parsed_url.path() != "/watch" { - return false; - } - let mut video_ids = parsed_url - .query_pairs() - .filter(|(key, _)| key == "v") - .map(|(_, value)| value); - return match (video_ids.next(), video_ids.next()) { - (Some(video_id), None) => is_youtube_video_id(video_id.as_ref()), - _ => false, - }; - } - - false -} - -fn youtube_missing_metadata_error(_parsed: &Value) -> String { - "YouTube import reported ok but missing metadata.".to_string() -} - -fn wait_for_process_output( - mut command: Command, - timeout: Duration, - poll_interval: Duration, - timeout_message: &str, -) -> Result { - let mut child = command - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|_| "Failed to start YouTube import process.".to_string())?; - let Some(stdout) = child.stdout.take() else { - let _ = child.kill(); - let _ = child.wait(); - return Err("Failed to execute YouTube import process.".to_string()); - }; - let Some(stderr) = child.stderr.take() else { - let _ = child.kill(); - let _ = child.wait(); - return Err("Failed to execute YouTube import process.".to_string()); - }; - let stdout_reader = thread::spawn(move || { - let mut reader = stdout; - let mut buffer = Vec::new(); - reader.read_to_end(&mut buffer).map(|_| buffer) - }); - let stderr_reader = thread::spawn(move || { - let mut reader = stderr; - let mut buffer = Vec::new(); - reader.read_to_end(&mut buffer).map(|_| buffer) - }); - let deadline = Instant::now() + timeout; - - loop { - match child.try_wait() { - Ok(Some(status)) => { - let stdout = stdout_reader - .join() - .map_err(|_| "Failed to execute YouTube import process.".to_string())? - .map_err(|_| "Failed to execute YouTube import process.".to_string())?; - let stderr = stderr_reader - .join() - .map_err(|_| "Failed to execute YouTube import process.".to_string())? - .map_err(|_| "Failed to execute YouTube import process.".to_string())?; - return Ok(std::process::Output { - status, - stdout, - stderr, - }); - } - Ok(None) => { - if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - let _ = stdout_reader.join(); - let _ = stderr_reader.join(); - return Err(timeout_message.to_string()); - } - thread::sleep(poll_interval); - } - Err(_) => { - let _ = child.kill(); - let _ = child.wait(); - let _ = stdout_reader.join(); - let _ = stderr_reader.join(); - return Err("Failed to execute YouTube import process.".to_string()); - } - } - } -} - -fn is_youtube_video_id(value: &str) -> bool { - value.len() == 11 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') -} - -fn project_payload_from_content(content: &str) -> Result { - if let Ok(parsed) = serde_json::from_str::(content) { - return Ok(parsed); - } - - let payload = serde_json::from_str::(content) - .map_err(|_| "Invalid project file format".to_string())?; - if let Some(sections) = payload.get("sections").and_then(Value::as_array) { - for (section_index, section) in sections.iter().enumerate() { - if section - .as_object() - .is_some_and(|section_object| !section_object.contains_key("timeRange")) - { - return Err(format!( - "Invalid project file format: sections[{section_index}].timeRange is required; reanalyze the project to restore section timing." - )); - } - } - } - - serde_json::from_value(payload).map_err(|_| "Invalid project file format".to_string()) -} - #[tauri::command] fn save_project(payload: Value) -> Result<(), String> { let parsed = serde_json::from_value::(payload) @@ -1233,276 +758,98 @@ fn load_project() -> Result { project_payload_from_content(&content) } -#[cfg(test)] -mod tests { - use super::*; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn unique_test_dir(name: &str) -> PathBuf { - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after epoch") - .as_nanos(); - std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) - } - - fn shared_contract_payload(time_range: Value) -> Value { - json!({ - "id": "demo-song", - "title": "Late Night Set", - "sections": [ - { - "id": "verse-1", - "label": "verse", - "groove": "Straight eighths with a late snare feel", - "timeRange": time_range, - "confidence": { - "level": "medium", - "source": "model", - "notes": "Double-check the pickup into the chorus." - }, - "roles": [ - { - "id": "bass-guitar", - "name": "Bass Guitar", - "roleType": "instrument", - "harmony": { - "chord": "C#m7", - "functionLabel": "vi pedal anchor", - "source": "model" - }, - "cue": { - "kind": "transition", - "value": "Hold through the pickup before the downbeat." - }, - "range": { - "lowestNote": "C#2", - "highestNote": "E3" - }, - "confidence": { - "level": "medium", - "source": "model", - "notes": "Watch the slide into the turnaround." - }, - "rehearsalPriority": "high", - "simplification": "Stay on roots if the chorus entrance gets muddy.", - "setupNote": "Keep the attack short so the verse breathes.", - "manualOverrides": [], - "overlapWarnings": [ - "Density warning: competing with Keyboard Left Hand in low register." - ] - } - ], - "partGraph": [ - { - "role_id": "bass-guitar", - "is_active": true, - "handoff_to": ["lead-vocal"], - "handoff_from": [] - } - ] - } - ], - "exportSummary": { - "format": "cue-sheet", - "headline": "Start with the verse handoff and low-register overlap.", - "focusSections": ["verse-1"] - } - }) - } - - #[test] - fn rehearsal_song_payload_accepts_shared_section_contract() { - let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); - - let parsed = serde_json::from_value::(payload) - .expect("shared rehearsal song contract should deserialize in Tauri"); - - assert_eq!(parsed.sections[0].id, "verse-1"); - } - - #[test] - fn rehearsal_song_payload_rejects_reversed_time_range() { - let payload = shared_contract_payload(json!({ "start": 30, "end": 10 })); - - assert!(serde_json::from_value::(payload).is_err()); - } - - #[test] - fn project_payload_from_content_rejects_legacy_missing_time_range() { - let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); - payload["sections"][0] - .as_object_mut() - .expect("section should be an object") - .remove("timeRange"); - let content = serde_json::to_string(&payload).expect("legacy payload should serialize"); - - let error = project_payload_from_content(&content) - .expect_err("legacy sections without timing should fail closed"); - - assert!(error.contains("timeRange")); - } +fn scores_root_for_project( + app: &tauri::AppHandle, + project_id: &str, +) -> Result { + // Callers must have validated `project_id` with `is_valid_project_id` + // before this join; the root stays inside the app-owned data directory. + let project_root = app_owned_root(app, "projects", project_id)?; + let root = project_root.join("scores"); + std::fs::create_dir_all(&root) + .map_err(|_| "Could not prepare the local scores workspace.".to_string())?; + Ok(root) +} - #[test] - fn youtube_url_validation_requires_exact_video_ids() { - assert!(is_supported_youtube_url( - "https://youtube.com/watch?v=abc123DEF45" - )); - assert!(is_supported_youtube_url( - "https://www.youtube.com/watch?v=abc123DEF45" - )); - assert!(is_supported_youtube_url("https://youtu.be/abc123DEF45")); - - assert!(!is_supported_youtube_url( - "https://evil.youtube.com/watch?v=abc123DEF45" - )); - assert!(!is_supported_youtube_url( - "https://youtube.com/watch?v=abc123" - )); - assert!(!is_supported_youtube_url( - "https://youtube.com/watch?v=abc123DEF4!" - )); - assert!(!is_supported_youtube_url("https://youtube.com/watch")); - assert!(!is_supported_youtube_url( - "https://youtube.com/watch?v=abc123DEF45&v=def456GHI78" - )); - assert!(!is_supported_youtube_url("https://youtu.be/abc123")); - assert!(!is_supported_youtube_url("https://youtu.be/abc123DEF4!")); +/// Security Notes: the file path comes exclusively from the OS file dialog +/// (never from JS), is validated (magic bytes, size, extension, no symlink), +/// and is copied into the app-owned scores directory. The stored copy is named +/// by a locally minted UUID v4, so no untrusted external path is ever +/// referenced again after this command returns. +#[tauri::command] +fn attach_score_pdf( + project_id: String, + song_id: String, + app: tauri::AppHandle, +) -> Result { + if !is_valid_project_id(&project_id) { + return Err("Invalid project id.".to_string()); } - - #[test] - fn youtube_missing_metadata_error_does_not_expose_payload() { - let parsed = json!({ - "ok": true, - "filepath": "/Users/someone/private-song.m4a", - "metadata": null - }); - - let message = youtube_missing_metadata_error(&parsed); - - assert_eq!(message, "YouTube import reported ok but missing metadata."); - assert!(!message.contains("private-song")); - assert!(!message.contains("filepath")); + // `song_id` is part of the viewer contract (score-to-song association is + // persisted on the JS side in a later slice); it never touches a path. + if song_id.trim().is_empty() { + return Err("Invalid song id.".to_string()); } - #[test] - fn youtube_process_timeout_kills_and_reaps_child() { - if std::env::var_os("BANDSCOPE_TEST_CHILD_SLEEP").is_some() { - thread::sleep(Duration::from_secs(5)); - return; - } - - let current_test_binary = std::env::current_exe().expect("test binary should resolve"); - let mut command = Command::new(current_test_binary); - command - .env("BANDSCOPE_TEST_CHILD_SLEEP", "1") - .arg("--exact") - .arg("tests::youtube_process_timeout_kills_and_reaps_child") - .arg("--nocapture"); - - let result = wait_for_process_output( - command, - Duration::from_millis(50), - Duration::from_millis(5), - "YouTube import timed out.", - ); + let path = FileDialog::new() + .add_filter("PDF Score", &["pdf"]) + .pick_file() + .ok_or_else(|| "Choose a PDF file to attach as a score.".to_string())?; + let (source, file_name, file_size_bytes) = validate_score_pdf_source(&path)?; + + let scores_root = scores_root_for_project(&app, &project_id)?; + let score_id = uuid::Uuid::new_v4().to_string(); + let destination = scores_root.join(format!("{score_id}.pdf")); + std::fs::copy(&source, &destination) + .map_err(|_| "Could not copy the PDF into the project workspace.".to_string())?; + + Ok(ScoreAttachmentPayload { + score_id, + file_name, + file_size_bytes, + }) +} - assert_eq!( - result.expect_err("slow child should time out"), - "YouTube import timed out." - ); +/// Security Notes: no path crosses the IPC boundary. Both ids are validated +/// against strict allowlist shapes, the path is rebuilt locally, and the +/// canonicalize-plus-prefix guard in `resolve_existing_score_pdf` rejects any +/// escape from the app-owned scores root. +#[tauri::command] +fn read_score_pdf( + project_id: String, + score_id: String, + app: tauri::AppHandle, +) -> Result, String> { + if !is_valid_project_id(&project_id) { + return Err("Invalid project id.".to_string()); } + let scores_root = scores_root_for_project(&app, &project_id)?; + let path = resolve_existing_score_pdf(&scores_root, &score_id)?; + std::fs::read(path).map_err(|_| "Could not read the score PDF.".to_string()) +} - #[test] - fn youtube_process_output_drains_large_stdout_and_stderr_before_exit() { - if std::env::var_os("BANDSCOPE_TEST_CHILD_LARGE_OUTPUT").is_some() { - let chunk = vec![b'x'; 1024 * 1024]; - std::io::stdout() - .write_all(&chunk) - .expect("child stdout should accept test bytes"); - std::io::stderr() - .write_all(&chunk) - .expect("child stderr should accept test bytes"); - return; - } - - let current_test_binary = std::env::current_exe().expect("test binary should resolve"); - let mut command = Command::new(current_test_binary); - command - .env("BANDSCOPE_TEST_CHILD_LARGE_OUTPUT", "1") - .arg("--exact") - .arg("tests::youtube_process_output_drains_large_stdout_and_stderr_before_exit") - .arg("--nocapture"); - - let output = wait_for_process_output( - command, - Duration::from_secs(2), - Duration::from_millis(5), - "YouTube import timed out.", - ) - .expect("large child output should be drained before timeout"); - - assert!(output.status.success()); - assert!(output.stdout.len() >= 1024 * 1024); - assert!(output.stderr.len() >= 1024 * 1024); +/// Security Notes: same id validation and traversal guard as `read_score_pdf`; +/// deletion is scoped to a single validated file inside the app-owned scores +/// root. Returns `false` when the score does not exist (idempotent removal). +#[tauri::command] +fn remove_score_pdf( + project_id: String, + score_id: String, + app: tauri::AppHandle, +) -> Result { + if !is_valid_project_id(&project_id) { + return Err("Invalid project id.".to_string()); } - - #[test] - fn youtube_metadata_must_reference_supported_audio_inside_cache_root() { - let cache_root = unique_test_dir("youtube-cache"); - let outside_root = unique_test_dir("youtube-outside"); - std::fs::create_dir_all(&cache_root).expect("cache root should be created"); - std::fs::create_dir_all(&outside_root).expect("outside root should be created"); - - let inside_file = cache_root.join("downloaded.m4a"); - let empty_file = cache_root.join("empty.m4a"); - let unsupported_file = cache_root.join("downloaded.txt"); - let outside_file = outside_root.join("downloaded.m4a"); - std::fs::write(&inside_file, b"audio").expect("inside file should be written"); - std::fs::write(&empty_file, b"").expect("empty file should be written"); - std::fs::write(&unsupported_file, b"not audio") - .expect("unsupported file should be written"); - std::fs::write(&outside_file, b"audio").expect("outside file should be written"); - - let accepted = youtube_source_from_metadata( - &json!({ "filepath": inside_file, "title": "Live/Test" }), - &cache_root, - ) - .expect("in-cache supported audio should be accepted"); - assert_eq!(accepted.extension, "m4a"); - assert_eq!(accepted.file_name, "Live_Test.m4a"); - - assert!(youtube_source_from_metadata( - &json!({ "filepath": empty_file, "title": "Live" }), - &cache_root, - ) - .is_err()); - assert!(youtube_source_from_metadata( - &json!({ "filepath": unsupported_file, "title": "Live" }), - &cache_root, - ) - .is_err()); - assert!(youtube_source_from_metadata( - &json!({ "filepath": outside_file, "title": "Live" }), - &cache_root, - ) - .is_err()); - - #[cfg(unix)] - { - let symlink_file = cache_root.join("linked.m4a"); - std::os::unix::fs::symlink(&inside_file, &symlink_file) - .expect("symlink should be created"); - assert!(youtube_source_from_metadata( - &json!({ "filepath": symlink_file, "title": "Live" }), - &cache_root, - ) - .is_err()); - } - - let _ = std::fs::remove_dir_all(cache_root); - let _ = std::fs::remove_dir_all(outside_root); + if !is_valid_score_id(&score_id) { + return Err("Invalid score id.".to_string()); } + let scores_root = scores_root_for_project(&app, &project_id)?; + let path = match resolve_existing_score_pdf(&scores_root, &score_id) { + Ok(path) => path, + Err(_) => return Ok(false), + }; + std::fs::remove_file(path).map_err(|_| "Could not remove the score PDF.".to_string())?; + Ok(true) } fn main() { @@ -1514,7 +861,10 @@ fn main() { start_analysis_job, get_analysis_job_status, save_project, - load_project + load_project, + attach_score_pdf, + read_score_pdf, + remove_score_pdf ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index c039dfba..e055a644 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1,7 +1,18 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; +// The Score view pulls in ScoreViewer -> pdfjs-dist, which needs DOMMatrix +// (absent in jsdom). Stub the pdf.js bridge so App can mount the real +// ScoreView without loading the WebGL/canvas-heavy library. +vi.mock("./features/score/pdfjs", () => ({ + configureScorePdfWorker: vi.fn(), + loadScorePdf: vi.fn(() => ({ + promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }), + destroy: vi.fn(() => Promise.resolve()) + })) +})); + const tauriInvoke = vi.fn(); const mockLoadProject = vi.fn(); const mockSaveProject = vi.fn(); @@ -1433,4 +1444,57 @@ describe("App", () => { expect(settingsSpan).toHaveAttribute("tabIndex", "0"); expect(settingsSpan).toHaveAttribute("role", "button"); }); + + it("keeps the Score view disabled until a song is loaded", () => { + render(); + + const scoreButtons = screen.getAllByRole("button", { name: /^Score$/i }); + expect(scoreButtons.length).toBeGreaterThan(0); + for (const button of scoreButtons) { + expect(button).toBeDisabled(); + } + expect(screen.queryByRole("heading", { name: /Score · Late Night Set/i })).toBeNull(); + }); + + it("switches to the Score view after a project is loaded", async () => { + mockLoadProject.mockResolvedValueOnce(succeededResult().result); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + await waitFor(() => { + expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); + }); + + const scoreButton = screen.getAllByRole("button", { name: /^Score$/i })[0]; + expect(scoreButton).toBeEnabled(); + fireEvent.click(scoreButton); + + expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument(); + // Projects opened from a .bscope file have no live workspace, so score + // storage is gated behind the active-project notice. + expect(screen.getByText(/Scores attach to the active analysis project/i)).toBeInTheDocument(); + expect(screen.queryByText(/Song Timeline/i)).toBeNull(); + }); + + it("switches to the Score view from the compact mobile navigation", async () => { + mockLoadProject.mockResolvedValueOnce(succeededResult().result); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + await waitFor(() => { + expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); + }); + + // The compact nav is a separate rendered bar (shown on small viewports) with + // its own set of buttons; exercise it directly so the mobile navigation path + // is covered, not just the sidebar one. + const compactNav = screen.getByRole("navigation", { name: /compact rehearsal views/i }); + const compactScoreButton = within(compactNav).getByRole("button", { name: /Score compact view/i }); + expect(compactScoreButton).toBeEnabled(); + + fireEvent.click(compactScoreButton); + + expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument(); + expect(screen.queryByText(/Song Timeline/i)).toBeNull(); + }); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 24f5fb09..fd86e310 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -40,7 +40,8 @@ import { selectLocalAudioSource, startAnalysisJob } from "./lib/analysis"; -import { createTranslator, detectPreferredLocale } from "./i18n"; +import { createTranslator, detectPreferredLocale, type TranslationKey } from "./i18n"; +import { ScoreView } from "./features/score/ScoreView"; import { Workspace } from "./features/workspace/Workspace"; import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates"; import { Button } from "@/components/ui/button"; @@ -53,17 +54,25 @@ const LOCAL_PATH_PATTERN = /(?:[A-Za-z]:[\\/][^\s"'<>]+|\\\\[^\s"'<>]+|\/(?:User const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>]+/gi; const SECRET_ASSIGNMENT_PATTERN = /\b(token|secret|password|api[_-]?key|access[_-]?token)\s*[:=]\s*[^\s,;]+/gi; -const NAV_ITEMS = [ - { label: "Workspace", icon: Home, active: true }, - { label: "Import", icon: Upload, active: false }, - { label: "Export", icon: Save, active: false }, - { label: "Sections", icon: ListMusic, active: false }, - { label: "Roles", icon: Users, active: false }, - { label: "Stem Lab", icon: AudioWaveform, active: false }, - { label: "Cues", icon: Sparkles, active: false }, - { label: "Transpose", icon: SlidersHorizontal, active: false }, - { label: "Notes", icon: FileMusic, active: false } -] as const; +/** Rehearsal views the sidebar can switch between today. */ +type RehearsalView = "workspace" | "score"; + +const NAV_ITEMS: ReadonlyArray<{ + label: string; + labelKey?: TranslationKey; + icon: typeof Home; + view: RehearsalView | null; +}> = [ + { label: "Workspace", icon: Home, view: "workspace" }, + { label: "Import", icon: Upload, view: null }, + { label: "Export", icon: Save, view: null }, + { label: "Sections", icon: ListMusic, view: null }, + { label: "Roles", icon: Users, view: null }, + { label: "Stem Lab", icon: AudioWaveform, view: null }, + { label: "Cues", icon: Sparkles, view: null }, + { label: "Transpose", icon: SlidersHorizontal, view: null }, + { label: "Score", labelKey: "navScore", icon: FileMusic, view: "score" } +]; const BRAND_BAR_HEIGHTS = ["h-3", "h-5", "h-7", "h-4", "h-6"] as const; @@ -227,6 +236,7 @@ export function App() { const [selectionError, setSelectionError] = useState(null); const [youtubeUrl, setYoutubeUrl] = useState(""); const [isImporting, setIsImporting] = useState(false); + const [activeView, setActiveView] = useState("workspace"); const activeJobIdRef = useRef(null); const analysisInFlight = jobStatus?.state === "queued" || jobStatus?.state === "running"; @@ -466,6 +476,25 @@ export function App() { return ; }; + // The score view needs a song; fall back to the workspace until one exists. + const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace"; + + /** Resolve label, enablement, and active state for one sidebar item. */ + const navButtonState = (item: (typeof NAV_ITEMS)[number]) => { + const enabled = item.view === "workspace" || (item.view === "score" && jobResult !== null); + return { + label: item.labelKey ? t(item.labelKey) : item.label, + enabled, + active: enabled && item.view === currentView, + title: enabled ? undefined : item.view === "score" ? t("scoreNavDisabledHint") : "Coming soon" + }; + }; + + /** Switch the main content to the clicked rehearsal view. */ + const handleNavSelect = (view: RehearsalView) => { + setActiveView(view); + }; + return (
@@ -492,24 +521,31 @@ export function App() {
@@ -553,23 +589,32 @@ export function App() {
@@ -732,7 +777,15 @@ export function App() {
- {renderWorkspaceState()} + {currentView === "score" && jobResult ? ( + + ) : ( + renderWorkspaceState() + )}
diff --git a/apps/desktop/src/features/score/ScoreView.test.tsx b/apps/desktop/src/features/score/ScoreView.test.tsx new file mode 100644 index 00000000..de4ccb95 --- /dev/null +++ b/apps/desktop/src/features/score/ScoreView.test.tsx @@ -0,0 +1,416 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types"; +import { invoke } from "@tauri-apps/api/core"; +import { ScoreView } from "./ScoreView"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn() +})); + +vi.mock("./ScoreViewer", () => ({ + ScoreViewer: ({ data, fileName }: { data: Uint8Array | null; fileName?: string }) => ( +
+ {data ? `bytes:${data.length}` : "no-data"} + {fileName ? `:${fileName}` : ""} +
+ ) +})); + +vi.mock("../../i18n", () => ({ + createTranslator: () => (key: string) => + ({ + scoreViewTitle: "Score", + scoreViewSubtitle: "Attach validated PDF scores to the current song.", + scoreListTitle: "Attached scores", + scoreListEmpty: "No scores attached to this song yet.", + scoreAttach: "Add score", + scoreAttaching: "Attaching...", + scoreRemove: "Remove", + scoreRemoveConfirm: "Remove {fileName} from this song?", + scoreOpen: "Open score", + scoreOpening: "Opening score PDF...", + scoreAttachFailed: "Could not attach the score PDF.", + scoreReadFailed: "Could not open the score PDF.", + scoreRemoveFailed: "Could not remove the score PDF.", + scoreRequiresProject: "Scores attach to the active analysis project." + })[key] ?? key, + detectPreferredLocale: () => "en" +})); + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: (command: string, args?: Record) => Promise; +}; + +const tauriWindow = window as TauriWindow; +const mockInvoke = vi.mocked(invoke); + +const SCORE_ID = "3f2c8f0e-1a2b-4c3d-8e9f-001122334455"; + +function makeSong(scoreAttachments?: ScoreAttachment[]): RehearsalSong { + return { + id: "song-1", + title: "Late Night Set", + sections: [], + exportSummary: { format: "cue-sheet", headline: "", focusSections: [] }, + ...(scoreAttachments ? { scoreAttachments } : {}) + } as RehearsalSong; +} + +function attachResponse(overrides: Record = {}) { + return { + scoreId: SCORE_ID, + fileName: "opener.pdf", + fileSizeBytes: 2048, + ...overrides + }; +} + +describe("ScoreView", () => { + beforeEach(() => { + mockInvoke.mockReset(); + tauriWindow.__TAURI_INTERNALS__ = { invoke: () => Promise.resolve(null) }; + delete tauriWindow.__TAURI_INVOKE__; + }); + + afterEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + vi.restoreAllMocks(); + }); + + it("renders the empty attachment list with an enabled attach button", () => { + render(); + + expect(screen.getByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument(); + expect(screen.getByText("No scores attached to this song yet.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add score" })).toBeEnabled(); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data"); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + it("disables score storage actions when no project workspace is active", () => { + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + render(); + + expect(screen.getByText("Scores attach to the active analysis project.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add score" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Open score: opener.pdf" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Remove: opener.pdf" })).toBeDisabled(); + + fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + it("attaches a score, persists the metadata, and opens the new PDF", async () => { + mockInvoke + .mockResolvedValueOnce(attachResponse()) + .mockResolvedValueOnce([1, 2, 3]); + const onSongUpdate = vi.fn(); + const song = makeSong(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Add score" })); + + await waitFor(() => { + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:3:opener.pdf"); + }); + expect(mockInvoke).toHaveBeenNthCalledWith(1, "attach_score_pdf", { + projectId: "project-1-2", + songId: "song-1" + }); + expect(mockInvoke).toHaveBeenNthCalledWith(2, "read_score_pdf", { + projectId: "project-1-2", + scoreId: SCORE_ID + }); + expect(onSongUpdate).toHaveBeenCalledWith({ + ...song, + scoreAttachments: [{ id: SCORE_ID, fileName: "opener.pdf" }] + }); + }); + + it("shows the bridge error when attaching fails and keeps metadata unchanged", async () => { + mockInvoke.mockRejectedValueOnce("Choose a PDF file to attach as a score."); + const onSongUpdate = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Add score" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Choose a PDF file to attach as a score." + ); + expect(onSongUpdate).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Add score" })).toBeEnabled(); + }); + + it("falls back to the generic attach failure for malformed bridge responses", async () => { + mockInvoke.mockResolvedValueOnce({ scoreId: 42 }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Add score" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Invalid score bridge response"); + }); + + it("opens an existing attachment through the read command", async () => { + const bytes = new Uint8Array([9, 9, 9, 9]).buffer; + let resolveRead!: (value: unknown) => void; + mockInvoke.mockImplementationOnce( + () => new Promise((resolve) => { resolveRead = resolve; }) + ); + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); + + expect(await screen.findByText("Opening score PDF...")).toBeInTheDocument(); + resolveRead(bytes); + + await waitFor(() => { + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:4:opener.pdf"); + }); + expect(mockInvoke).toHaveBeenCalledWith("read_score_pdf", { + projectId: "project-1-2", + scoreId: SCORE_ID + }); + }); + + it("accepts Uint8Array read responses from the bridge", async () => { + mockInvoke.mockResolvedValueOnce(new Uint8Array([7, 7])); + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); + + await waitFor(() => { + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:2:opener.pdf"); + }); + }); + + it("clears the selection and reports when reading a score fails", async () => { + mockInvoke.mockRejectedValueOnce(new Error("Score was not found.")); + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not open the score PDF. Score was not found." + ); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data"); + }); + + it("rejects malformed read responses", async () => { + mockInvoke.mockResolvedValueOnce("not-bytes"); + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not open the score PDF. Invalid score bridge response" + ); + }); + + it("removes an attachment after confirmation and resets the open viewer", async () => { + mockInvoke + .mockResolvedValueOnce([1, 2]) + .mockResolvedValueOnce(true); + vi.spyOn(window, "confirm").mockReturnValue(true); + const onSongUpdate = vi.fn(); + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); + await waitFor(() => { + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:2:opener.pdf"); + }); + + fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); + + await waitFor(() => { + expect(onSongUpdate).toHaveBeenCalledWith({ ...song, scoreAttachments: [] }); + }); + expect(window.confirm).toHaveBeenCalledWith("Remove opener.pdf from this song?"); + expect(mockInvoke).toHaveBeenCalledWith("remove_score_pdf", { + projectId: "project-1-2", + scoreId: SCORE_ID + }); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data"); + }); + + it("keeps the attachment when the removal confirm is declined", () => { + vi.spyOn(window, "confirm").mockReturnValue(false); + const onSongUpdate = vi.fn(); + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); + + expect(mockInvoke).not.toHaveBeenCalled(); + expect(onSongUpdate).not.toHaveBeenCalled(); + }); + + it("reports removal failures without dropping the metadata", async () => { + mockInvoke.mockRejectedValueOnce(new Error("Could not remove the score PDF.")); + vi.spyOn(window, "confirm").mockReturnValue(true); + const onSongUpdate = vi.fn(); + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Could not remove the score PDF."); + expect(onSongUpdate).not.toHaveBeenCalled(); + }); + + it("rejects malformed removal responses", async () => { + mockInvoke.mockResolvedValueOnce("done"); + vi.spyOn(window, "confirm").mockReturnValue(true); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Invalid score bridge response"); + }); + + it("fails closed when no desktop bridge is available", async () => { + delete tauriWindow.__TAURI_INTERNALS__; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Add score" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Score PDFs are only available in the desktop app." + ); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + it("uses the legacy invoke shim when Tauri internals are absent", async () => { + delete tauriWindow.__TAURI_INTERNALS__; + const legacyInvoke = vi.fn().mockResolvedValueOnce([5]); + tauriWindow.__TAURI_INVOKE__ = legacyInvoke; + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); + + await waitFor(() => { + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:1:opener.pdf"); + }); + expect(legacyInvoke).toHaveBeenCalledWith("read_score_pdf", { + projectId: "project-1-2", + scoreId: SCORE_ID + }); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + it("falls back to the generic attach copy when the bridge rejects with a non-textual value", async () => { + // A rejection that is neither an Error nor a string exercises the + // `bridgeErrorDetail` fallback path (no usable message to surface). + mockInvoke.mockRejectedValueOnce({ code: 500 }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Add score" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Could not attach the score PDF."); + }); + + it("ignores a superseded read once a newer attachment is opened", async () => { + // Opening a second score before the first read resolves must make the + // stale first read a no-op (last-open-wins), so the viewer keeps the newer + // score and the stale resolution never overwrites it. + let resolveStale!: (value: unknown) => void; + mockInvoke + .mockImplementationOnce(() => new Promise((resolve) => { resolveStale = resolve; })) + .mockResolvedValueOnce([9, 9]); + const song = makeSong([ + { id: "id-1", fileName: "first.pdf" }, + { id: "id-2", fileName: "second.pdf" } + ]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open score: first.pdf" })); + fireEvent.click(screen.getByRole("button", { name: "Open score: second.pdf" })); + + await waitFor(() => { + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:2:second.pdf"); + }); + + await act(async () => { + resolveStale([1, 1, 1, 1, 1]); + }); + + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:2:second.pdf"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("swallows a superseded read failure instead of surfacing a stale error", async () => { + // A rejected stale read must not clobber the newer, successful selection + // with an error banner. + let rejectStale!: (reason: unknown) => void; + mockInvoke + .mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectStale = reject; })) + .mockResolvedValueOnce([4, 4]); + const song = makeSong([ + { id: "id-1", fileName: "first.pdf" }, + { id: "id-2", fileName: "second.pdf" } + ]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open score: first.pdf" })); + fireEvent.click(screen.getByRole("button", { name: "Open score: second.pdf" })); + + await waitFor(() => { + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:2:second.pdf"); + }); + + await act(async () => { + rejectStale(new Error("Stale read failed.")); + }); + + expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:2:second.pdf"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("removes a score that is not currently open without resetting the viewer", async () => { + // With nothing open, removal updates metadata but must leave the (empty) + // viewer state untouched. + mockInvoke.mockResolvedValueOnce(true); + vi.spyOn(window, "confirm").mockReturnValue(true); + const onSongUpdate = vi.fn(); + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); + + await waitFor(() => { + expect(onSongUpdate).toHaveBeenCalledWith({ ...song, scoreAttachments: [] }); + }); + expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data"); + }); +}); diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx new file mode 100644 index 00000000..72732450 --- /dev/null +++ b/apps/desktop/src/features/score/ScoreView.tsx @@ -0,0 +1,230 @@ +import { useMemo, useRef, useState } from "react"; +import { FileMusic, FilePlus2, Loader2, Trash2 } from "lucide-react"; +import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { ScoreViewer } from "./ScoreViewer"; +import { attachScorePdf, readScorePdf, removeScorePdf } from "./scoreStorage"; + +/** Props accepted by the per-song score attachments view. */ +export interface ScoreViewProps { + /** Song whose score attachments are listed and updated. */ + song: RehearsalSong; + /** + * Active analysis project id, or `null` when the song was loaded without a + * live project workspace (demo songs, `.bscope` files opened directly). + * Score PDFs live in the project workspace, so all storage actions are + * disabled without it. + */ + projectId: string | null; + /** Callback receiving the song with updated `scoreAttachments` metadata. */ + onSongUpdate: (song: RehearsalSong) => void; +} + +/** + * Extract the first line of a bridge error for display, falling back to the + * provided message when the error carries no usable text. + */ +function bridgeErrorDetail(error: unknown, fallback: string): string { + const raw = error instanceof Error ? error.message : typeof error === "string" ? error : null; + const firstLine = raw?.split(/\r?\n/)[0]?.trim(); + return firstLine ? firstLine : fallback; +} + +/** + * Score view for the current song: lists attached score PDFs, attaches new + * ones through the validated desktop bridge, opens a selected score in the + * embedded viewer, and removes attachments (metadata plus stored copy). + */ +export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const attachments = useMemo(() => song.scoreAttachments ?? [], [song.scoreAttachments]); + const [selected, setSelected] = useState(null); + const [pdfBytes, setPdfBytes] = useState(null); + const [isAttaching, setIsAttaching] = useState(false); + const [isOpening, setIsOpening] = useState(false); + const [error, setError] = useState(null); + const readRequestRef = useRef(0); + + /** + * Load the stored PDF bytes for an attachment into the viewer. Callers pass + * the active project id explicitly; the storage controls are only wired up + * (and enabled) when a workspace is present, so this never runs without one. + */ + const openAttachment = async (activeProjectId: string, attachment: ScoreAttachment) => { + const requestId = readRequestRef.current + 1; + readRequestRef.current = requestId; + setSelected(attachment); + setPdfBytes(null); + setError(null); + setIsOpening(true); + try { + const bytes = await readScorePdf(activeProjectId, attachment.id); + if (readRequestRef.current === requestId) { + setPdfBytes(bytes); + } + } catch (readError) { + if (readRequestRef.current === requestId) { + setSelected(null); + setError(`${t("scoreReadFailed")} ${bridgeErrorDetail(readError, "")}`.trim()); + } + } finally { + if (readRequestRef.current === requestId) { + setIsOpening(false); + } + } + }; + + /** + * Attach a new score PDF via the native picker and open it. The attach + * control is disabled while `isAttaching`, so overlapping attaches cannot be + * started; the active project id is supplied by the enabled control. + */ + const handleAttach = async (activeProjectId: string) => { + setError(null); + setIsAttaching(true); + try { + const result = await attachScorePdf(activeProjectId, song.id); + const attachment: ScoreAttachment = { id: result.id, fileName: result.fileName }; + onSongUpdate({ ...song, scoreAttachments: [...attachments, attachment] }); + setIsAttaching(false); + await openAttachment(activeProjectId, attachment); + } catch (attachError) { + setIsAttaching(false); + setError(bridgeErrorDetail(attachError, t("scoreAttachFailed"))); + } + }; + + /** Remove an attachment after confirmation (metadata and stored copy). */ + const handleRemove = async (activeProjectId: string, attachment: ScoreAttachment) => { + const confirmed = window.confirm( + t("scoreRemoveConfirm").replace("{fileName}", attachment.fileName) + ); + if (!confirmed) { + return; + } + setError(null); + try { + await removeScorePdf(activeProjectId, attachment.id); + onSongUpdate({ + ...song, + scoreAttachments: attachments.filter((entry) => entry.id !== attachment.id) + }); + if (selected?.id === attachment.id) { + readRequestRef.current += 1; + setSelected(null); + setPdfBytes(null); + setIsOpening(false); + } + } catch (removeError) { + setError(bridgeErrorDetail(removeError, t("scoreRemoveFailed"))); + } + }; + + return ( +
+ + +
+
+

+ {t("scoreViewTitle")} · {song.title} +

+

{t("scoreViewSubtitle")}

+
+ +
+ + {!projectId && ( +

+ {t("scoreRequiresProject")} +

+ )} + + {error && ( +

+ {error} +

+ )} + +
+

+ {t("scoreListTitle")} +

+ {attachments.length === 0 ? ( +

{t("scoreListEmpty")}

+ ) : ( +
    + {attachments.map((attachment) => ( +
  • + + +
  • + ))} +
+ )} +
+
+
+ + {isOpening ? ( + + + + + ) : ( + + )} +
+ ); +} diff --git a/apps/desktop/src/features/score/ScoreViewer.test.tsx b/apps/desktop/src/features/score/ScoreViewer.test.tsx new file mode 100644 index 00000000..297e1d42 --- /dev/null +++ b/apps/desktop/src/features/score/ScoreViewer.test.tsx @@ -0,0 +1,329 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist"; +import { ScoreViewer } from "./ScoreViewer"; +import { loadScorePdf } from "./pdfjs"; + +vi.mock("./pdfjs", () => ({ + loadScorePdf: vi.fn() +})); + +vi.mock("../../i18n", () => ({ + createTranslator: () => (key: string) => + ({ + scoreViewerEmpty: "No score PDF attached. Attach a validated score PDF to view it here.", + scoreViewerLoading: "Loading score PDF...", + scoreViewerFailedTitle: "Could not display the score", + scoreViewerRetry: "Retry", + scoreViewerPrevPage: "Previous page", + scoreViewerNextPage: "Next page", + scoreViewerPageIndicator: "Page {current} of {total}", + scoreViewerZoomIn: "Zoom in", + scoreViewerZoomOut: "Zoom out", + scoreViewerFitWidth: "Fit width" + })[key] ?? key, + detectPreferredLocale: () => "en" +})); + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function createFakePage(renderPromise: Promise = Promise.resolve()) { + const renderTask = { promise: renderPromise, cancel: vi.fn() }; + return { + renderTask, + getViewport: vi.fn(({ scale }: { scale: number }) => ({ + width: 600 * scale, + height: 800 * scale + })), + render: vi.fn(() => renderTask) + }; +} + +function createFakeDocument(numPages = 3, page = createFakePage()) { + return { + page, + doc: { + numPages, + getPage: vi.fn(() => Promise.resolve(page)) + } as unknown as PDFDocumentProxy + }; +} + +function mockLoadTaskOnce( + promise: Promise, + destroy: () => Promise = () => Promise.resolve() +) { + const destroyMock = vi.fn(destroy); + vi.mocked(loadScorePdf).mockReturnValueOnce({ + promise, + destroy: destroyMock + } as unknown as PDFDocumentLoadingTask); + return { destroy: destroyMock }; +} + +const SAMPLE_BYTES = new Uint8Array([0x25, 0x50, 0x44, 0x46]); + +describe("ScoreViewer", () => { + beforeEach(() => { + vi.mocked(loadScorePdf).mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders the empty placeholder without loading when no data is attached", () => { + const onStatusChange = vi.fn(); + render(); + + expect( + screen.getByText("No score PDF attached. Attach a validated score PDF to view it here.") + ).toBeInTheDocument(); + expect(loadScorePdf).not.toHaveBeenCalled(); + expect(onStatusChange).not.toHaveBeenCalled(); + }); + + it("transitions from LOADING to READY and renders the first page", async () => { + const deferred = createDeferred(); + mockLoadTaskOnce(deferred.promise); + const { doc, page } = createFakeDocument(3); + const onStatusChange = vi.fn(); + + render(); + + expect(screen.getByRole("status")).toBeInTheDocument(); + expect(screen.getByText("Loading score PDF...")).toBeInTheDocument(); + expect(onStatusChange).toHaveBeenLastCalledWith("LOADING"); + expect(loadScorePdf).toHaveBeenCalledWith(SAMPLE_BYTES); + + await act(async () => { + deferred.resolve(doc); + }); + + expect(await screen.findByText("Page 1 of 3")).toBeInTheDocument(); + expect(onStatusChange).toHaveBeenLastCalledWith("READY"); + await waitFor(() => { + expect(page.render).toHaveBeenCalled(); + }); + expect(page.getViewport).toHaveBeenCalledWith({ scale: 1 }); + expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled(); + }); + + it("shows the file name when provided", async () => { + const { doc } = createFakeDocument(1); + mockLoadTaskOnce(Promise.resolve(doc)); + + render(); + + expect(await screen.findByText("setlist-opener.pdf")).toBeInTheDocument(); + }); + + it("transitions to FAILED with the error message and recovers on retry", async () => { + mockLoadTaskOnce(Promise.reject(new Error("broken bytes"))); + const { doc } = createFakeDocument(2); + mockLoadTaskOnce(Promise.resolve(doc)); + const onStatusChange = vi.fn(); + + render(); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(screen.getByText("Could not display the score")).toBeInTheDocument(); + expect(screen.getByText("broken bytes")).toBeInTheDocument(); + expect(onStatusChange).toHaveBeenLastCalledWith("FAILED"); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(await screen.findByText("Page 1 of 2")).toBeInTheDocument(); + expect(onStatusChange).toHaveBeenLastCalledWith("READY"); + expect(loadScorePdf).toHaveBeenCalledTimes(2); + }); + + it("stringifies non-Error load failures", async () => { + mockLoadTaskOnce(Promise.reject("password protected")); + + render(); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(screen.getByText("password protected")).toBeInTheDocument(); + }); + + it("navigates pages and clamps at both bounds", async () => { + const { doc } = createFakeDocument(3); + mockLoadTaskOnce(Promise.resolve(doc)); + + render(); + + expect(await screen.findByText("Page 1 of 3")).toBeInTheDocument(); + const previousButton = screen.getByRole("button", { name: "Previous page" }); + const nextButton = screen.getByRole("button", { name: "Next page" }); + expect(previousButton).toBeDisabled(); + + fireEvent.click(nextButton); + expect(screen.getByText("Page 2 of 3")).toBeInTheDocument(); + + fireEvent.click(nextButton); + expect(screen.getByText("Page 3 of 3")).toBeInTheDocument(); + expect(nextButton).toBeDisabled(); + + await waitFor(() => { + expect(doc.getPage).toHaveBeenCalledWith(3); + }); + + fireEvent.click(previousButton); + expect(screen.getByText("Page 2 of 3")).toBeInTheDocument(); + await waitFor(() => { + expect(doc.getPage).toHaveBeenCalledWith(2); + }); + }); + + it("zooms in and out with clamping and returns to fit-width", async () => { + const { doc, page } = createFakeDocument(1); + mockLoadTaskOnce(Promise.resolve(doc)); + + render(); + + expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); + const zoomInButton = screen.getByRole("button", { name: "Zoom in" }); + const zoomOutButton = screen.getByRole("button", { name: "Zoom out" }); + const fitWidthButton = screen.getByRole("button", { name: "Fit width" }); + expect(fitWidthButton).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click(zoomInButton); + expect(fitWidthButton).toHaveAttribute("aria-pressed", "false"); + await waitFor(() => { + expect(page.getViewport).toHaveBeenCalledWith({ scale: 1.25 }); + }); + + for (let clicks = 0; clicks < 8; clicks += 1) { + fireEvent.click(zoomInButton); + } + await waitFor(() => { + expect(page.getViewport).toHaveBeenCalledWith({ scale: 4 }); + }); + + for (let clicks = 0; clicks < 12; clicks += 1) { + fireEvent.click(zoomOutButton); + } + await waitFor(() => { + expect(page.getViewport).toHaveBeenCalledWith({ scale: 0.5 }); + }); + + fireEvent.click(fitWidthButton); + expect(fitWidthButton).toHaveAttribute("aria-pressed", "true"); + }); + + it("re-renders at fit-width scale when the container resizes", async () => { + let resizeCallback: ResizeObserverCallback | null = null; + class FakeResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + } + vi.stubGlobal("ResizeObserver", FakeResizeObserver); + + const { doc, page } = createFakeDocument(1); + mockLoadTaskOnce(Promise.resolve(doc)); + + render(); + + expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); + + act(() => { + resizeCallback?.( + [{ contentRect: { width: 300 } } as ResizeObserverEntry], + {} as ResizeObserver + ); + }); + + await waitFor(() => { + expect(page.getViewport).toHaveBeenCalledWith({ scale: 0.5 }); + }); + }); + + it("keeps the READY layout when a page render is cancelled mid-flight", async () => { + const renderFailure = Promise.reject(new Error("Rendering cancelled")); + renderFailure.catch(() => undefined); + const page = createFakePage(renderFailure); + const { doc } = createFakeDocument(1, page); + mockLoadTaskOnce(Promise.resolve(doc)); + + render(); + + expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); + await waitFor(() => { + expect(page.render).toHaveBeenCalled(); + }); + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + }); + + it("keeps the READY layout when fetching a page fails after load", async () => { + const doc = { + numPages: 1, + getPage: vi.fn(() => Promise.reject(new Error("destroyed"))) + } as unknown as PDFDocumentProxy; + mockLoadTaskOnce(Promise.resolve(doc)); + + render(); + + expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); + await waitFor(() => { + expect(doc.getPage).toHaveBeenCalled(); + }); + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + }); + + it("destroys the loading task on unmount and ignores late results", async () => { + const deferred = createDeferred(); + const { destroy } = mockLoadTaskOnce(deferred.promise, () => + Promise.reject(new Error("already destroyed")) + ); + const onStatusChange = vi.fn(); + + const { unmount } = render( + + ); + unmount(); + + expect(destroy).toHaveBeenCalledTimes(1); + + const { doc } = createFakeDocument(1); + await act(async () => { + deferred.resolve(doc); + }); + expect(onStatusChange).not.toHaveBeenCalledWith("READY"); + }); + + it("ignores a late failure after unmount", async () => { + const deferred = createDeferred(); + mockLoadTaskOnce(deferred.promise); + const onStatusChange = vi.fn(); + + const { unmount } = render( + + ); + unmount(); + + await act(async () => { + deferred.reject(new Error("too late")); + }); + expect(onStatusChange).not.toHaveBeenCalledWith("FAILED"); + }); +}); diff --git a/apps/desktop/src/features/score/ScoreViewer.tsx b/apps/desktop/src/features/score/ScoreViewer.tsx new file mode 100644 index 00000000..82692469 --- /dev/null +++ b/apps/desktop/src/features/score/ScoreViewer.tsx @@ -0,0 +1,317 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist"; +import { + AlertCircle, + ChevronLeft, + ChevronRight, + FileMusic, + Loader2, + MoveHorizontal, + RotateCw, + ZoomIn, + ZoomOut, +} from "lucide-react"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { loadScorePdf } from "./pdfjs"; + +/** Viewer lifecycle states following the clearfolio LOADING/FAILED/READY contract. */ +export type ScoreViewerStatus = "LOADING" | "FAILED" | "READY"; + +/** Props accepted by the score PDF viewer. */ +export interface ScoreViewerProps { + /** + * Validated score PDF bytes to display, or `null` when no score is + * attached. Following the validated-resource-only rule the viewer never + * loads arbitrary URLs; callers (PR3 wires Tauri `read_score_pdf`) must + * hand it bytes they already validated. + */ + data: Uint8Array | null; + /** Optional display name of the attached score file. */ + fileName?: string; + /** Optional observer notified on every LOADING/FAILED/READY transition. */ + onStatusChange?: (status: ScoreViewerStatus) => void; +} + +const ZOOM_STEP = 1.25; +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 4; + +/** + * Render a score PDF from validated in-memory bytes with pdf.js. + * + * Implements the clearfolio viewer state machine (LOADING spinner, FAILED + * error with retry, READY canvas) plus rehearsal-friendly page navigation + * and zoom in/out/fit-width controls. + */ +export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps) { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const [status, setStatus] = useState("LOADING"); + const [errorMessage, setErrorMessage] = useState(null); + const [pdfDocument, setPdfDocument] = useState(null); + const [pageNumber, setPageNumber] = useState(1); + const [pageCount, setPageCount] = useState(0); + const [zoom, setZoom] = useState(1); + const [fitWidth, setFitWidth] = useState(true); + const [containerWidth, setContainerWidth] = useState(0); + const [retryToken, setRetryToken] = useState(0); + const canvasRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + if (data !== null) { + onStatusChange?.(status); + } + }, [data, status, onStatusChange]); + + useEffect(() => { + if (data === null) { + return; + } + + let cancelled = false; + setStatus("LOADING"); + setErrorMessage(null); + setPdfDocument(null); + + const loadingTask = loadScorePdf(data); + loadingTask.promise + .then((loadedDocument) => { + if (cancelled) { + return; + } + setPdfDocument(loadedDocument); + setPageCount(loadedDocument.numPages); + setPageNumber(1); + setStatus("READY"); + }) + .catch((error: unknown) => { + if (cancelled) { + return; + } + setErrorMessage(error instanceof Error ? error.message : String(error)); + setStatus("FAILED"); + }); + + return () => { + cancelled = true; + void loadingTask.destroy().catch(() => undefined); + }; + }, [data, retryToken]); + + useEffect(() => { + const container = containerRef.current; + if (status !== "READY" || !container || typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + observer.observe(container); + return () => observer.disconnect(); + }, [status]); + + useEffect(() => { + const canvas = canvasRef.current; + if (status !== "READY" || !pdfDocument || !canvas) { + return; + } + + let cancelled = false; + let renderTask: RenderTask | null = null; + + pdfDocument + .getPage(pageNumber) + .then((page) => { + if (cancelled) { + return; + } + const baseViewport = page.getViewport({ scale: 1 }); + const scale = + fitWidth && containerWidth > 0 ? containerWidth / baseViewport.width : zoom; + const viewport = page.getViewport({ scale }); + canvas.width = Math.floor(viewport.width); + canvas.height = Math.floor(viewport.height); + renderTask = page.render({ canvas, viewport }); + renderTask.promise.catch(() => { + // Cancelled renders (rapid page/zoom changes) are expected. + }); + }) + .catch(() => { + // The document was destroyed mid-flight; the load effect owns errors. + }); + + return () => { + cancelled = true; + renderTask?.cancel(); + }; + }, [status, pdfDocument, pageNumber, zoom, fitWidth, containerWidth]); + + /** Move to the previous page, clamped at the first page. */ + const goToPreviousPage = () => { + setPageNumber((current) => Math.max(1, current - 1)); + }; + + /** Move to the next page, clamped at the last page. */ + const goToNextPage = () => { + setPageNumber((current) => Math.min(pageCount, current + 1)); + }; + + /** Switch to manual zoom and enlarge, clamped at the maximum scale. */ + const zoomIn = () => { + setFitWidth(false); + setZoom((current) => Math.min(MAX_ZOOM, current * ZOOM_STEP)); + }; + + /** Switch to manual zoom and shrink, clamped at the minimum scale. */ + const zoomOut = () => { + setFitWidth(false); + setZoom((current) => Math.max(MIN_ZOOM, current / ZOOM_STEP)); + }; + + /** Re-enable fit-width so the page tracks the container size. */ + const fitToWidth = () => { + setFitWidth(true); + }; + + /** Re-run the load state machine with the same validated bytes. */ + const retry = () => { + setRetryToken((current) => current + 1); + }; + + if (data === null) { + return ( + + +
+
+

{t("scoreViewerEmpty")}

+
+
+ ); + } + + if (status === "LOADING") { + return ( + + + + + ); + } + + if (status === "FAILED") { + return ( + + +
+
+

{t("scoreViewerFailedTitle")}

+ {errorMessage && ( +

+ {errorMessage} +

+ )} + +
+
+ ); + } + + const pageIndicator = t("scoreViewerPageIndicator") + .replace("{current}", String(pageNumber)) + .replace("{total}", String(pageCount)); + + return ( + + +
+ {fileName && ( +
+
+ )} +
+ + + +
+
+
+ +
+
+ + + {pageIndicator} + + +
+
+
+ ); +} diff --git a/apps/desktop/src/features/score/pdfjs.ts b/apps/desktop/src/features/score/pdfjs.ts new file mode 100644 index 00000000..b62526c8 --- /dev/null +++ b/apps/desktop/src/features/score/pdfjs.ts @@ -0,0 +1,29 @@ +import { getDocument, GlobalWorkerOptions, type PDFDocumentLoadingTask } from "pdfjs-dist"; +import scorePdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; + +/** + * Point pdf.js at the locally bundled worker asset. + * + * The worker URL is resolved by Vite from the pinned `pdfjs-dist` package at + * build time and emitted as a same-origin asset, so it satisfies the Tauri + * `script-src 'self'` Content Security Policy. No CDN or remote script is + * ever referenced. + */ +export function configureScorePdfWorker(): void { + if (GlobalWorkerOptions.workerSrc !== scorePdfWorkerUrl) { + GlobalWorkerOptions.workerSrc = scorePdfWorkerUrl; + } +} + +/** + * Start parsing validated in-memory score PDF bytes with pdf.js. + * + * Only caller-provided bytes are accepted (validated-resource-only rule); + * this helper never fetches arbitrary URLs. The bytes are copied before they + * are handed to pdf.js because pdf.js transfers the underlying buffer to its + * worker, which would otherwise detach the caller's copy and break retries. + */ +export function loadScorePdf(data: Uint8Array): PDFDocumentLoadingTask { + configureScorePdfWorker(); + return getDocument({ data: new Uint8Array(data) }); +} diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts new file mode 100644 index 00000000..0feec199 --- /dev/null +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { attachScorePdf, readScorePdf, removeScorePdf } from "./scoreStorage"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: (command: string, args?: Record) => Promise; +}; + +const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app."; + +describe("scoreStorage bridge resolution", () => { + afterEach(() => { + vi.unstubAllGlobals(); + const tauriWindow = window as TauriWindow; + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + }); + + it("fails closed on every command when there is no window (non-browser runtime)", async () => { + // Simulate a runtime without a DOM window (e.g. SSR / bundler prerender): + // getInvoke() must take the `typeof window === "undefined"` branch and + // return null so callers fail closed instead of dereferencing `window`. + vi.stubGlobal("window", undefined); + + await expect(attachScorePdf("project-1", "song-1")).rejects.toThrow( + BRIDGE_UNAVAILABLE_MESSAGE + ); + await expect(readScorePdf("project-1", "score-1")).rejects.toThrow( + BRIDGE_UNAVAILABLE_MESSAGE + ); + await expect(removeScorePdf("project-1", "score-1")).rejects.toThrow( + BRIDGE_UNAVAILABLE_MESSAGE + ); + }); +}); diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts new file mode 100644 index 00000000..492f1259 --- /dev/null +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -0,0 +1,112 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { ScoreAttachment } from "@bandscope/shared-types"; + +type TauriInvoke = (command: string, args?: Record) => Promise; + +type TauriBridgeWindow = Window & { + __TAURI_INTERNALS__?: { invoke?: unknown }; + __TAURI_INVOKE__?: TauriInvoke; +}; + +/** + * Attachment metadata plus the validated on-disk size reported by the + * desktop bridge when a score PDF is copied into the project workspace. + */ +export type ScoreAttachResult = ScoreAttachment & { fileSizeBytes: number }; + +const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app."; +const INVALID_RESPONSE_MESSAGE = "Invalid score bridge response"; + +/** + * Resolve the desktop invoke bridge following the same detection rules as + * the analysis bridge: prefer Tauri v2 internals, fall back to the legacy + * test/dev shim, and return null in plain browsers. + */ +function getInvoke(): TauriInvoke | null { + if (typeof window === "undefined") { + return null; + } + + const bridgeWindow = window as TauriBridgeWindow; + if (bridgeWindow.__TAURI_INTERNALS__ && typeof bridgeWindow.__TAURI_INTERNALS__.invoke === "function") { + return invoke; + } + + if (typeof bridgeWindow.__TAURI_INVOKE__ === "function") { + return bridgeWindow.__TAURI_INVOKE__; + } + + return null; +} + +/** + * Invoke a score storage command on the desktop bridge, failing closed with + * a stable error when no bridge is available (browser preview builds). + */ +async function invokeScoreCommand(command: string, args: Record): Promise { + const invokeCommand = getInvoke(); + if (!invokeCommand) { + throw new Error(BRIDGE_UNAVAILABLE_MESSAGE); + } + + return invokeCommand(command, args); +} + +/** + * Open the native PDF picker and copy the validated score into the + * app-owned project workspace. Security Notes: the file path never crosses + * the IPC boundary from JS; the Rust command owns the dialog, validation + * (magic bytes, size cap, no symlinks), and the copy destination. + */ +export async function attachScorePdf(projectId: string, songId: string): Promise { + const response = await invokeScoreCommand("attach_score_pdf", { projectId, songId }); + if ( + typeof response !== "object" || + response === null || + typeof (response as Record).scoreId !== "string" || + typeof (response as Record).fileName !== "string" || + typeof (response as Record).fileSizeBytes !== "number" + ) { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + + const payload = response as { scoreId: string; fileName: string; fileSizeBytes: number }; + return { + id: payload.scoreId, + fileName: payload.fileName, + fileSizeBytes: payload.fileSizeBytes + }; +} + +/** + * Read the validated score PDF bytes for a previously attached score. + * Security Notes: only allowlisted ids cross the IPC boundary; the Rust + * command rebuilds and canonicalizes the path inside the app-owned root. + */ +export async function readScorePdf(projectId: string, scoreId: string): Promise { + const response = await invokeScoreCommand("read_score_pdf", { projectId, scoreId }); + if (response instanceof Uint8Array) { + return response; + } + if (response instanceof ArrayBuffer) { + return new Uint8Array(response); + } + if (Array.isArray(response) && response.every((byte) => typeof byte === "number")) { + return Uint8Array.from(response as number[]); + } + + throw new Error(INVALID_RESPONSE_MESSAGE); +} + +/** + * Delete the stored score PDF copy. Resolves to false when the file was + * already gone so callers can treat removal as idempotent. + */ +export async function removeScorePdf(projectId: string, scoreId: string): Promise { + const response = await invokeScoreCommand("remove_score_pdf", { projectId, scoreId }); + if (typeof response !== "boolean") { + throw new Error(INVALID_RESPONSE_MESSAGE); + } + + return response; +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 4e098662..5e8497b5 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -62,6 +62,32 @@ "roleSwitcherTitle": "Role-specific View", "allRoles": "All Roles", "overlapWarning": "Clash warning", + "scoreViewerEmpty": "No score PDF attached. Attach a validated score PDF to view it here.", + "scoreViewerLoading": "Loading score PDF...", + "scoreViewerFailedTitle": "Could not display the score", + "scoreViewerRetry": "Retry", + "scoreViewerPrevPage": "Previous page", + "scoreViewerNextPage": "Next page", + "scoreViewerPageIndicator": "Page {current} of {total}", + "scoreViewerZoomIn": "Zoom in", + "scoreViewerZoomOut": "Zoom out", + "scoreViewerFitWidth": "Fit width", + "navScore": "Score", + "scoreViewTitle": "Score", + "scoreViewSubtitle": "Attach validated PDF scores to the current song and read them during rehearsal.", + "scoreListTitle": "Attached scores", + "scoreListEmpty": "No scores attached to this song yet.", + "scoreAttach": "Add score", + "scoreAttaching": "Attaching...", + "scoreRemove": "Remove", + "scoreRemoveConfirm": "Remove {fileName} from this song? The PDF copy stored on this device is deleted too.", + "scoreOpen": "Open score", + "scoreOpening": "Opening score PDF...", + "scoreAttachFailed": "Could not attach the score PDF.", + "scoreReadFailed": "Could not open the score PDF.", + "scoreRemoveFailed": "Could not remove the score PDF.", + "scoreRequiresProject": "Scores attach to the active analysis project. Analyze local audio or a YouTube import first.", + "scoreNavDisabledHint": "Analyze or open a song first", "youtubePlaceholder": "YouTube URL...", "importYoutube": "Import YouTube", "importingYoutube": "Importing...", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 2b59d5ee..356db312 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -62,6 +62,32 @@ "roleSwitcherTitle": "악기/보컬 역할", "allRoles": "전체 보기", "overlapWarning": "충돌 주의", + "scoreViewerEmpty": "첨부된 악보 PDF가 없습니다. 검증된 악보 PDF를 첨부하면 여기에 표시됩니다.", + "scoreViewerLoading": "악보 PDF를 불러오는 중...", + "scoreViewerFailedTitle": "악보를 표시할 수 없습니다", + "scoreViewerRetry": "다시 시도", + "scoreViewerPrevPage": "이전 페이지", + "scoreViewerNextPage": "다음 페이지", + "scoreViewerPageIndicator": "{total}페이지 중 {current}페이지", + "scoreViewerZoomIn": "확대", + "scoreViewerZoomOut": "축소", + "scoreViewerFitWidth": "폭 맞춤", + "navScore": "악보", + "scoreViewTitle": "악보", + "scoreViewSubtitle": "현재 곡에 검증된 PDF 악보를 첨부하고 합주 중에 바로 펼쳐 볼 수 있습니다.", + "scoreListTitle": "첨부된 악보", + "scoreListEmpty": "이 곡에 첨부된 악보가 아직 없습니다.", + "scoreAttach": "악보 추가", + "scoreAttaching": "첨부하는 중...", + "scoreRemove": "삭제", + "scoreRemoveConfirm": "{fileName} 악보를 이 곡에서 삭제할까요? 이 기기에 저장된 PDF 사본도 함께 삭제됩니다.", + "scoreOpen": "악보 열기", + "scoreOpening": "악보 PDF를 여는 중...", + "scoreAttachFailed": "악보 PDF를 첨부하지 못했습니다.", + "scoreReadFailed": "악보 PDF를 열지 못했습니다.", + "scoreRemoveFailed": "악보 PDF를 삭제하지 못했습니다.", + "scoreRequiresProject": "악보는 활성 분석 프로젝트에 첨부됩니다. 먼저 로컬 오디오나 유튜브 가져오기로 분석을 실행하세요.", + "scoreNavDisabledHint": "먼저 곡을 분석하거나 프로젝트를 여세요", "youtubePlaceholder": "유튜브 URL...", "importYoutube": "유튜브 가져오기", "importingYoutube": "가져오는 중...", diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 45902137..f1db6f2b 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -19,7 +19,14 @@ export default defineConfig({ setupFiles: ["./src/setupTests.ts"], coverage: { provider: "v8", - include: ["src/App.tsx", "src/lib/export.ts", "src/i18n/index.ts"], + include: [ + "src/App.tsx", + "src/lib/export.ts", + "src/i18n/index.ts", + "src/features/score/ScoreViewer.tsx", + "src/features/score/ScoreView.tsx", + "src/features/score/scoreStorage.ts" + ], thresholds: { lines: 90, functions: 90, diff --git a/package-lock.json b/package-lock.json index 8a479475..f22ce9aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.20.0", + "pdfjs-dist": "6.1.200", "react": "^19.2.4", "react-dom": "^19.2.7", "tailwind-merge": "^3.6.0", @@ -1018,6 +1019,256 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", + "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.2", + "@napi-rs/canvas-darwin-arm64": "1.0.2", + "@napi-rs/canvas-darwin-x64": "1.0.2", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", + "@napi-rs/canvas-linux-arm64-musl": "1.0.2", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-musl": "1.0.2", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", + "@napi-rs/canvas-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.2.tgz", + "integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.2.tgz", + "integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.2.tgz", + "integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -1140,9 +1391,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1160,9 +1408,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1180,9 +1425,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1200,9 +1442,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1220,9 +1459,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1240,9 +1476,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1482,9 +1715,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1502,9 +1732,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1522,9 +1749,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1542,9 +1766,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3850,6 +4071,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "6.1.200", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.1.200.tgz", + "integrity": "sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index ff04618b..ca4fde05 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -211,6 +211,12 @@ export type RehearsalWorkspace = { workspaceVersion: number; }; +/** Documented. */ +export type ScoreAttachment = { + id: string; + fileName: string; +}; + /** Documented. */ export type RehearsalSong = { id: string; @@ -218,6 +224,7 @@ export type RehearsalSong = { sections: RehearsalSection[]; exportSummary: ExportSummary; collaboration?: RehearsalCollaboration; + scoreAttachments?: ScoreAttachment[]; }; /** Documented. */ @@ -1727,6 +1734,25 @@ function migrateLegacySectionTimeRanges(value: unknown): unknown { return migrated; } +/** Documented. */ +function validateScoreAttachment(value: unknown, path: string): string | null { + if (!isRecord(value)) { + return invalidField(path); + } + const extraKey = unexpectedKey(value, ["id", "fileName"], path); + if (extraKey) { + return extraKey; + } + if (typeof value.id !== "string" || value.id.length === 0) { + return invalidField(`${path}.id`); + } + if (typeof value.fileName !== "string" || value.fileName.length === 0) { + return invalidField(`${path}.fileName`); + } + + return null; +} + /** Documented. */ function validateRehearsalSong( value: unknown, @@ -1738,7 +1764,7 @@ function validateRehearsalSong( if (!isRecord(normalized)) { return invalidField("root"); } - const extraKey = unexpectedKey(normalized, ["id", "title", "sections", "exportSummary", "collaboration"], ""); + const extraKey = unexpectedKey(normalized, ["id", "title", "sections", "exportSummary", "collaboration", "scoreAttachments"], ""); if (extraKey) { return extraKey; } @@ -1763,6 +1789,17 @@ function validateRehearsalSong( return collaborationError; } } + if (normalized.scoreAttachments !== undefined) { + if (!isDenseArray(normalized.scoreAttachments)) { + return invalidField("scoreAttachments"); + } + for (const [index, attachment] of normalized.scoreAttachments.entries()) { + const attachmentError = validateScoreAttachment(attachment, `scoreAttachments[${index}]`); + if (attachmentError) { + return attachmentError; + } + } + } return validateExportSummary(normalized.exportSummary, "exportSummary"); } diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index bd2bc6fa..d03538c5 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -756,6 +756,33 @@ describe("shared type helpers", () => { })).toThrow("exportSummary.format"); }); + it("round-trips score attachment metadata and rejects malformed entries", () => { + const song = createDemoRehearsalSong() as unknown as Record; + const attachment = { id: "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", fileName: "opener.pdf" }; + + expect(parseRehearsalSong({ ...song, scoreAttachments: [attachment] }).scoreAttachments).toEqual([attachment]); + expect(parseRehearsalSong({ ...song }).scoreAttachments).toBeUndefined(); + + expect(() => parseRehearsalSong({ ...song, scoreAttachments: {} })).toThrow("scoreAttachments"); + expect(() => parseRehearsalSong({ ...song, scoreAttachments: [null] })).toThrow("scoreAttachments[0]"); + expect(() => parseRehearsalSong({ + ...song, + scoreAttachments: [{ ...attachment, extra: true }] + })).toThrow("scoreAttachments[0].extra"); + expect(() => parseRehearsalSong({ + ...song, + scoreAttachments: [{ id: "", fileName: "opener.pdf" }] + })).toThrow("scoreAttachments[0].id"); + expect(() => parseRehearsalSong({ + ...song, + scoreAttachments: [{ id: 42, fileName: "opener.pdf" }] + })).toThrow("scoreAttachments[0].id"); + expect(() => parseRehearsalSong({ + ...song, + scoreAttachments: [{ id: attachment.id, fileName: "" }] + })).toThrow("scoreAttachments[0].fileName"); + }); + it("reports the first invalid field path for nested contract failures", () => { const roleSparse = createDemoRehearsalSong() as unknown as { sections: Array<{ roles: unknown[] }>;