diff --git a/.gitattributes b/.gitattributes index 08aa10e..72e47cc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,3 +15,6 @@ crates/prism-sys/vendor/**/*.dll binary crates/prism-sys/vendor/**/*.dylib binary crates/prism-sys/vendor/**/*.so binary crates/prism-sys/vendor/**/*.lib binary + +# Bundled sound pack audio. +crates/portkeydrop-core/assets/**/*.ogg binary diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c412c..5eb45fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file. - A "Waiting to connect" cue now loops while an SFTP connection is held up waiting for your SSH agent to approve the key. Agents such as Bitwarden show that approval in a box that can open behind the Portkey Drop window with nothing to say it is there; the sound fills that gap and stops the moment the connection succeeds or fails. Give it a sound by adding a `connect_waiting` entry to a sound pack, and mute it in Settings like any other cue. ### Fixed +- A fresh install made no sound at all. The default sound pack was written with an empty list and none of its audio, so only people upgrading from the Python version, whose old pack was carried across, heard any cues. The twenty default sounds now ship inside the program and are written out on first start, including the new "Waiting to connect" cue, which is also added to an existing default pack without touching any sound you have replaced. - Sound cues played in mono, most audibly the connect sound. The opening fraction of a second of every cue was folded to a single channel and slightly stretched before playback settled into stereo, and the short cues carry their stereo image right at the start. Cues now play in full stereo from the first sample. - Backspace, Alt+Left, and Alt+Up in a file pane now go to the parent directory. Those keys were bound to a list event that never reported which key was pressed, so they did nothing in either pane. Ctrl+Up and Ctrl+[ do the same (Command+Up and Command+[ on a Mac, matching Finder). - The exit sound was cut off as the program closed. Closing now waits for it to finish. diff --git a/crates/portkeydrop-core/assets/soundpacks/default/connections/connect_failed.ogg b/crates/portkeydrop-core/assets/soundpacks/default/connections/connect_failed.ogg new file mode 100644 index 0000000..dd540b5 Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/connections/connect_failed.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/connections/connect_success.ogg b/crates/portkeydrop-core/assets/soundpacks/default/connections/connect_success.ogg new file mode 100644 index 0000000..15f2d68 Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/connections/connect_success.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/connections/disconnect.ogg b/crates/portkeydrop-core/assets/soundpacks/default/connections/disconnect.ogg new file mode 100644 index 0000000..71e5554 Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/connections/disconnect.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/file_operations/delete_complete.ogg b/crates/portkeydrop-core/assets/soundpacks/default/file_operations/delete_complete.ogg new file mode 100644 index 0000000..8e85d72 Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/file_operations/delete_complete.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/file_operations/delete_failed.ogg b/crates/portkeydrop-core/assets/soundpacks/default/file_operations/delete_failed.ogg new file mode 100644 index 0000000..f8d109d Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/file_operations/delete_failed.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/file_operations/folder_create_failed.ogg b/crates/portkeydrop-core/assets/soundpacks/default/file_operations/folder_create_failed.ogg new file mode 100644 index 0000000..e400b43 Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/file_operations/folder_create_failed.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/file_operations/folder_created.ogg b/crates/portkeydrop-core/assets/soundpacks/default/file_operations/folder_created.ogg new file mode 100644 index 0000000..ab9de48 Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/file_operations/folder_created.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/file_operations/rename_complete.ogg b/crates/portkeydrop-core/assets/soundpacks/default/file_operations/rename_complete.ogg new file mode 100644 index 0000000..87f92ac Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/file_operations/rename_complete.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/file_operations/rename_failed.ogg b/crates/portkeydrop-core/assets/soundpacks/default/file_operations/rename_failed.ogg new file mode 100644 index 0000000..8e35224 Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/file_operations/rename_failed.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/general/error.ogg b/crates/portkeydrop-core/assets/soundpacks/default/general/error.ogg new file mode 100644 index 0000000..9772d65 Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/general/error.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/general/exit.ogg b/crates/portkeydrop-core/assets/soundpacks/default/general/exit.ogg new file mode 100644 index 0000000..2ae9aab Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/general/exit.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/general/notify.ogg b/crates/portkeydrop-core/assets/soundpacks/default/general/notify.ogg new file mode 100644 index 0000000..fe888a6 Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/general/notify.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/general/startup.ogg b/crates/portkeydrop-core/assets/soundpacks/default/general/startup.ogg new file mode 100644 index 0000000..baf6ef6 Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/general/startup.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/general/success.ogg b/crates/portkeydrop-core/assets/soundpacks/default/general/success.ogg new file mode 100644 index 0000000..68a1a8f Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/general/success.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_cancelled.ogg b/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_cancelled.ogg new file mode 100644 index 0000000..d9df0ea Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_cancelled.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_complete.ogg b/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_complete.ogg new file mode 100644 index 0000000..53b1cba Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_complete.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_failed.ogg b/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_failed.ogg new file mode 100644 index 0000000..6672a8d Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_failed.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_queued.ogg b/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_queued.ogg new file mode 100644 index 0000000..c6b739b Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_queued.ogg differ diff --git a/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_started.ogg b/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_started.ogg new file mode 100644 index 0000000..ad382fa Binary files /dev/null and b/crates/portkeydrop-core/assets/soundpacks/default/transfers/transfer_started.ogg differ diff --git a/crates/portkeydrop-core/src/soundpacks/builtin.rs b/crates/portkeydrop-core/src/soundpacks/builtin.rs new file mode 100644 index 0000000..a76b355 --- /dev/null +++ b/crates/portkeydrop-core/src/soundpacks/builtin.rs @@ -0,0 +1,105 @@ +//! The audio for the default pack, compiled into the binary. +//! +//! Shipping the files inside the executable means a fresh install, a nightly, +//! and a portable copy all have sound on first launch with no installer step +//! to forget. [`super::ensure_default_pack`] writes them to the packs +//! directory, where the user can replace any of them. + +/// One sound in the built-in pack. +pub struct BuiltinSound { + /// The event key, see [`crate::sound_events`]. + pub event: &'static str, + /// Path inside the pack directory, `/`-separated. + pub path: &'static str, + /// The encoded audio. + pub bytes: &'static [u8], +} + +macro_rules! sound { + ($event:literal, $path:literal) => { + BuiltinSound { + event: $event, + path: $path, + bytes: include_bytes!(concat!("../../assets/soundpacks/default/", $path)), + } + }; +} + +/// Every sound the default pack ships with. +pub const BUILTIN_SOUNDS: &[BuiltinSound] = &[ + sound!("transfer_queued", "transfers/transfer_queued.ogg"), + sound!("transfer_started", "transfers/transfer_started.ogg"), + sound!("transfer_complete", "transfers/transfer_complete.ogg"), + sound!("transfer_failed", "transfers/transfer_failed.ogg"), + sound!("transfer_cancelled", "transfers/transfer_cancelled.ogg"), + sound!("connect_waiting", "connections/connect_waiting.ogg"), + sound!("connect_success", "connections/connect_success.ogg"), + sound!("connect_failed", "connections/connect_failed.ogg"), + sound!("disconnect", "connections/disconnect.ogg"), + sound!("delete_complete", "file_operations/delete_complete.ogg"), + sound!("delete_failed", "file_operations/delete_failed.ogg"), + sound!("rename_complete", "file_operations/rename_complete.ogg"), + sound!("rename_failed", "file_operations/rename_failed.ogg"), + sound!("folder_created", "file_operations/folder_created.ogg"), + sound!( + "folder_create_failed", + "file_operations/folder_create_failed.ogg" + ), + sound!("success", "general/success.ogg"), + sound!("error", "general/error.ogg"), + sound!("notify", "general/notify.ogg"), + sound!("startup", "general/startup.ogg"), + sound!("exit", "general/exit.ogg"), +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::sound_events::{is_known_sound_event, SOUND_EVENT_SECTIONS}; + use crate::soundpacks::can_decode; + use std::collections::HashSet; + use tempfile::TempDir; + + #[test] + fn every_built_in_sound_is_a_known_event() { + for sound in BUILTIN_SOUNDS { + assert!( + is_known_sound_event(sound.event), + "{} is not in the event catalogue", + sound.event + ); + } + } + + #[test] + fn every_catalogue_event_has_a_built_in_sound() { + // A fresh install should make a sound for everything Settings lists, + // otherwise muting an event there would be a switch wired to nothing. + let shipped: HashSet<&str> = BUILTIN_SOUNDS.iter().map(|s| s.event).collect(); + for section in SOUND_EVENT_SECTIONS { + for (event, _) in section.events { + assert!(shipped.contains(event), "no built-in sound for {event}"); + } + } + } + + #[test] + fn events_and_paths_are_unique() { + let events: HashSet<&str> = BUILTIN_SOUNDS.iter().map(|s| s.event).collect(); + let paths: HashSet<&str> = BUILTIN_SOUNDS.iter().map(|s| s.path).collect(); + assert_eq!(events.len(), BUILTIN_SOUNDS.len()); + assert_eq!(paths.len(), BUILTIN_SOUNDS.len()); + } + + #[test] + fn every_built_in_sound_decodes() { + // Guards against a corrupt or misnamed asset getting compiled in. + let dir = TempDir::new().unwrap(); + for sound in BUILTIN_SOUNDS { + assert!(!sound.bytes.is_empty(), "{} is empty", sound.path); + let path = dir.path().join(sound.event).with_extension("ogg"); + std::fs::write(&path, sound.bytes).unwrap(); + assert!(can_decode(&path), "{} does not decode", sound.path); + } + } +} diff --git a/crates/portkeydrop-core/src/soundpacks/manifest.rs b/crates/portkeydrop-core/src/soundpacks/manifest.rs index 5b3699f..fe77961 100644 --- a/crates/portkeydrop-core/src/soundpacks/manifest.rs +++ b/crates/portkeydrop-core/src/soundpacks/manifest.rs @@ -8,6 +8,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +use super::builtin::BUILTIN_SOUNDS; use super::PackError; /// One sound in a manifest. @@ -18,9 +19,9 @@ pub enum SoundEntry { File(String), /// `"transfer_complete": {"file": "done.ogg", "volume": 0.5}` Detailed { - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] file: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] volume: Option, }, } @@ -61,7 +62,7 @@ pub struct PackManifest { #[serde(default)] pub sounds: BTreeMap, /// Fallback volumes, for entries written as a bare file name. - #[serde(default)] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub volumes: BTreeMap, } @@ -124,17 +125,36 @@ impl PackManifest { } } + /// Serialise as pretty-printed JSON with a trailing newline. + pub fn to_json(&self) -> String { + serde_json::to_string_pretty(self).expect("a manifest always serialises") + "\n" + } + + /// The manifest for the built-in default pack, listing every sound in + /// [`BUILTIN_SOUNDS`]. + pub fn default_pack_manifest() -> Self { + PackManifest { + name: "Default".into(), + author: "Portkey Drop".into(), + description: + "Built-in Portkey Drop sound pack with short, gentle transfer and app cues.".into(), + version: "1.0.0".into(), + sounds: BUILTIN_SOUNDS + .iter() + .map(|sound| { + ( + sound.event.to_string(), + SoundEntry::File(sound.path.to_string()), + ) + }) + .collect(), + volumes: BTreeMap::new(), + } + } + /// The manifest written for a freshly created default pack. pub fn default_pack_json() -> String { - serde_json::to_string_pretty(&serde_json::json!({ - "name": "Default", - "author": "Portkey Drop", - "description": "Default sound pack.", - "version": "1.0.0", - "sounds": {}, - })) - .expect("a literal JSON object always serialises") - + "\n" + Self::default_pack_manifest().to_json() } } @@ -266,9 +286,29 @@ mod tests { } #[test] - fn the_generated_default_manifest_is_valid_and_empty() { + fn the_generated_default_manifest_lists_every_built_in_sound() { let manifest = PackManifest::from_json(&PackManifest::default_pack_json()).unwrap(); assert_eq!(manifest.name, "Default"); - assert!(manifest.sounds.is_empty()); + assert_eq!(manifest.sounds.len(), BUILTIN_SOUNDS.len()); + for sound in BUILTIN_SOUNDS { + assert_eq!( + manifest.sounds[sound.event].file_name(sound.event), + sound.path + ); + } + } + + #[test] + fn a_manifest_round_trips_through_json_without_null_fields() { + // The default pack setup rewrites a user's manifest to add missing + // entries, so what it writes back must be as clean as what it read. + let manifest = PackManifest::from_json( + r#"{"name":"P","sounds":{"error":{"volume":0.5},"exit":"x.ogg"}}"#, + ) + .unwrap(); + let text = manifest.to_json(); + assert!(!text.contains("null"), "{text}"); + assert!(!text.contains("volumes"), "{text}"); + assert_eq!(PackManifest::from_json(&text).unwrap(), manifest); } } diff --git a/crates/portkeydrop-core/src/soundpacks/mod.rs b/crates/portkeydrop-core/src/soundpacks/mod.rs index 1752bee..f887082 100644 --- a/crates/portkeydrop-core/src/soundpacks/mod.rs +++ b/crates/portkeydrop-core/src/soundpacks/mod.rs @@ -7,6 +7,7 @@ //! Packs are user-supplied content, so installation is treated as untrusted //! input: archives are checked for path traversal before anything is written. +mod builtin; mod install; mod manifest; mod player; @@ -14,6 +15,7 @@ mod player; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +pub use builtin::{BuiltinSound, BUILTIN_SOUNDS}; pub use install::{is_safe_archive_name, InstallError, PackInstaller}; pub use manifest::{PackManifest, SoundEntry}; pub use player::{ @@ -199,33 +201,59 @@ pub fn resolve_sound( /// Ensure the built-in default pack exists in the writable packs directory. /// -/// Existing files are never overwritten: a user who replaced a sound keeps -/// their version across upgrades. +/// Every sound in [`BUILTIN_SOUNDS`] that is not already on disk is written, +/// and the manifest gains an entry for every built-in event it does not name. +/// Existing files and entries are never overwritten: a user who replaced a +/// sound keeps their version across upgrades, and a new built-in cue still +/// reaches them. pub fn ensure_default_pack(soundpacks_dir: &Path) -> std::io::Result { let default_dir = soundpacks_dir.join(DEFAULT_PACK); std::fs::create_dir_all(&default_dir)?; + for sound in BUILTIN_SOUNDS { + let path = default_dir.join(sound.path); + if path.exists() { + continue; + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, sound.bytes)?; + } + let manifest_path = default_dir.join(MANIFEST_FILE_NAME); - if should_write_default_manifest(&manifest_path) { - std::fs::write(&manifest_path, PackManifest::default_pack_json())?; + if let Some(text) = default_manifest_update(&manifest_path) { + std::fs::write(&manifest_path, text)?; } Ok(default_dir) } -/// Whether the default manifest should be (re)written. +/// The default manifest text to write, or `None` to leave the file alone. /// -/// An empty `sounds` map is the placeholder an earlier install wrote before any -/// audio existed; replacing that is safe. A manifest with entries is the user's -/// and is left alone. -fn should_write_default_manifest(manifest_path: &Path) -> bool { +/// A missing manifest gets the built-in one. A readable manifest keeps +/// everything it has and only gains entries for built-in events it lacks, so +/// it is rewritten only when that adds something. +fn default_manifest_update(manifest_path: &Path) -> Option { let Ok(text) = std::fs::read_to_string(manifest_path) else { - return true; + return Some(PackManifest::default_pack_json()); }; match PackManifest::from_json(&text) { - Ok(manifest) => manifest.sounds.is_empty(), + Ok(mut manifest) => { + let mut changed = false; + for sound in BUILTIN_SOUNDS { + if !manifest.sounds.contains_key(sound.event) { + manifest.sounds.insert( + sound.event.to_string(), + SoundEntry::File(sound.path.to_string()), + ); + changed = true; + } + } + changed.then(|| manifest.to_json()) + } // Unparseable: leave it alone rather than destroying something the // user may be part-way through editing. - Err(_) => false, + Err(_) => None, } } @@ -448,23 +476,51 @@ mod tests { } #[test] - fn the_default_pack_is_created_when_absent() { + fn the_default_pack_is_created_with_every_built_in_sound() { let dir = TempDir::new().unwrap(); let packs = soundpacks_dir(dir.path()); let default = ensure_default_pack(&packs).unwrap(); - assert!(default.join(MANIFEST_FILE_NAME).exists()); - assert!(validate_pack(&default).is_ok()); + + let manifest = validate_pack(&default).unwrap(); + assert_eq!(manifest.sounds.len(), BUILTIN_SOUNDS.len()); + for sound in BUILTIN_SOUNDS { + let written = std::fs::read(default.join(sound.path)).unwrap(); + assert_eq!(written, sound.bytes, "{} differs on disk", sound.path); + assert!(resolve_sound(sound.event, DEFAULT_PACK, &packs).is_some()); + } + } + + #[test] + fn a_users_replacement_sound_is_not_overwritten() { + // A user who swapped in their own connect chime keeps it across + // upgrades; only files that are missing get written. + let dir = TempDir::new().unwrap(); + let packs = soundpacks_dir(dir.path()); + let theirs = packs + .join(DEFAULT_PACK) + .join("connections/connect_success.ogg"); + std::fs::create_dir_all(theirs.parent().unwrap()).unwrap(); + std::fs::write(&theirs, b"my chime").unwrap(); + + ensure_default_pack(&packs).unwrap(); + + assert_eq!(std::fs::read(&theirs).unwrap(), b"my chime"); + assert!(packs + .join(DEFAULT_PACK) + .join("connections/disconnect.ogg") + .exists()); } #[test] - fn a_user_edited_default_manifest_is_left_alone() { - // Overwriting it would silently discard the user's customisation. + fn a_user_edited_default_manifest_keeps_its_entries_and_gains_the_rest() { + // Overwriting it would silently discard the user's customisation, but + // a built-in cue added in a later release still has to reach them. let dir = TempDir::new().unwrap(); let packs = soundpacks_dir(dir.path()); write_pack( &packs, DEFAULT_PACK, - r#"{"name":"Mine","sounds":{"error":"e.ogg"}}"#, + r#"{"name":"Mine","sounds":{"error":{"file":"e.ogg","volume":0.5}}}"#, &["e.ogg"], ); @@ -472,10 +528,30 @@ mod tests { let manifest = validate_pack(&packs.join(DEFAULT_PACK)).unwrap(); assert_eq!(manifest.name, "Mine"); + assert_eq!(manifest.sounds["error"].file_name("error"), "e.ogg"); + assert_eq!(manifest.sounds["error"].volume(), Some(0.5)); + assert_eq!(manifest.sounds.len(), BUILTIN_SOUNDS.len()); + assert!(manifest.sounds.contains_key("connect_waiting")); + } + + #[test] + fn a_complete_default_manifest_is_not_rewritten() { + let dir = TempDir::new().unwrap(); + let packs = soundpacks_dir(dir.path()); + let manifest_path = packs.join(DEFAULT_PACK).join(MANIFEST_FILE_NAME); + std::fs::create_dir_all(manifest_path.parent().unwrap()).unwrap(); + // Hand-formatted, so a rewrite would be visible. + let text = PackManifest::default_pack_json().replace(" ", "\t"); + std::fs::write(&manifest_path, &text).unwrap(); + + ensure_default_pack(&packs).unwrap(); + + assert_eq!(std::fs::read_to_string(&manifest_path).unwrap(), text); } #[test] - fn an_empty_placeholder_default_manifest_is_replaced() { + fn an_empty_placeholder_default_manifest_is_filled_in() { + // Earlier builds wrote a manifest with no sounds at all. let dir = TempDir::new().unwrap(); let packs = soundpacks_dir(dir.path()); write_pack( @@ -487,9 +563,8 @@ mod tests { ensure_default_pack(&packs).unwrap(); - let text = - std::fs::read_to_string(packs.join(DEFAULT_PACK).join(MANIFEST_FILE_NAME)).unwrap(); - assert_eq!(text, PackManifest::default_pack_json()); + let manifest = validate_pack(&packs.join(DEFAULT_PACK)).unwrap(); + assert_eq!(manifest.sounds.len(), BUILTIN_SOUNDS.len()); } #[test]