Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
105 changes: 105 additions & 0 deletions crates/portkeydrop-core/src/soundpacks/builtin.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
68 changes: 54 additions & 14 deletions crates/portkeydrop-core/src/soundpacks/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
volume: Option<f64>,
},
}
Expand Down Expand Up @@ -61,7 +62,7 @@ pub struct PackManifest {
#[serde(default)]
pub sounds: BTreeMap<String, SoundEntry>,
/// Fallback volumes, for entries written as a bare file name.
#[serde(default)]
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub volumes: BTreeMap<String, f64>,
}

Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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);
}
}
Loading