diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b982db9..65ae505 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,6 +3,8 @@ name: Release
on:
pull_request:
push:
+ branches:
+ - main
tags:
- "v*"
@@ -29,7 +31,7 @@ jobs:
name: Validate release identity
shell: pwsh
env:
- EXPECTED_TAG: ${{ github.event_name == 'push' && github.ref_name || '' }}
+ EXPECTED_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
run: |
$info = ./scripts/Test-ReleaseVersion.ps1 -ExpectedTag $env:EXPECTED_TAG -PassThru
"version=$($info.Version)" >> $env:GITHUB_OUTPUT
@@ -61,7 +63,8 @@ jobs:
run: |
cargo fmt --all -- --check
cargo check --workspace --locked
- cargo test --workspace --locked
+ # Binding persistence tests mutate process-wide HOME/APPDATA.
+ cargo test --workspace --locked -- --test-threads=1
- name: Parse release scripts
shell: pwsh
run: |
@@ -76,6 +79,7 @@ jobs:
}
}
if ($failed) { exit 1 }
+ ./scripts/Test-ValidationWorkflow.ps1
- name: Check frontend
working-directory: apps/tauri
shell: pwsh
@@ -96,7 +100,7 @@ jobs:
build-windows:
name: Build Windows installers
- if: github.event_name == 'push'
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: [plan, validate]
runs-on: windows-2022
steps:
@@ -125,7 +129,7 @@ jobs:
publish:
name: Publish verified draft
- if: github.event_name == 'push'
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: [plan, build-windows]
runs-on: windows-2022
permissions:
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..998172e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,3 @@
+# Repository instructions
+
+Use `postigodev/` as the prefix for working branches, including future branches.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4d3cdc7..e0f12a4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -34,7 +34,7 @@ If you want to contribute code, start by opening an issue or picking an existing
- Rust toolchain
- Android Platform Tools with `adb` in `PATH`
- Fire TV with ADB debugging enabled for manual device testing
-- Spotify Developer app credentials for Spotify auth flows
+- Spotify Developer app client ID for Spotify PKCE auth flows
After installing Android Platform Tools, open a new PowerShell window and verify:
@@ -48,8 +48,9 @@ If `adb` is not found, add the Platform Tools directory to the Windows user
machine must be on the same network, ADB debugging must be enabled on the TV,
and the TV may show an authorization prompt on the first connection.
-Spotify flows require a Spotify Developer app. Use the app's client ID, client
-secret, and the redirect URL expected by Sendo. When testing playback routing,
+Spotify flows require a Spotify Developer app. Use its public client ID and
+register the HTTP loopback redirect URL expected by Sendo. Authorization Code
+with PKCE does not require a client secret. When testing playback routing,
select the explicit Spotify Connect target in the app instead of relying on the
currently active Spotify device.
diff --git a/Cargo.lock b/Cargo.lock
index d8ef9be..48f185e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -700,6 +700,7 @@ name = "desk_remote_core"
version = "0.1.0"
dependencies = [
"anyhow",
+ "async-trait",
"rand 0.9.2",
"rspotify",
"serde",
diff --git a/apps/site/app/install/page.tsx b/apps/site/app/install/page.tsx
index 3050cdb..d920a9a 100644
--- a/apps/site/app/install/page.tsx
+++ b/apps/site/app/install/page.tsx
@@ -19,7 +19,7 @@ const requirements = [
"Windows",
"adb available from a new PowerShell window",
"Fire TV with ADB debugging enabled and authorized",
- "Spotify Premium plus developer app credentials",
+ "Spotify Premium plus a developer app client ID",
"Explicit Spotify Connect target selected in Sendo",
];
@@ -40,7 +40,7 @@ export default async function InstallPage() {
Download the Windows build, enable Fire TV control over ADB,
- connect Spotify with the correct OAuth credentials, select
+ connect Spotify with the public client ID and redirect URL, select
the intended target device, and keep Sendo available from the
tray for repeatable media actions.
@@ -151,7 +151,7 @@ export default async function InstallPage() {
The install itself is straightforward. Most first-run failures
come from missing ADB, Fire TV debugging not enabled or not
- authorized, Spotify redirect credentials not matching, or
+ authorized, the Spotify client ID or redirect URL not matching, or
selecting the wrong Spotify device.
@@ -196,7 +196,7 @@ export default async function InstallPage() {
03
- Authenticate Spotify with the developer app credentials and
+ Authenticate Spotify using PKCE with the developer app client ID and
explicitly select a target.
@@ -341,8 +341,8 @@ function TroubleshootingBody({ title }: { title: string }) {
-
- Confirm the client ID, client secret, and redirect URL match the
- values entered in Sendo.
+ Confirm the client ID and registered loopback redirect URL match the
+ values entered in Sendo. PKCE does not require a client secret.
diff --git a/apps/site/content/site.ts b/apps/site/content/site.ts
index 7666bcc..e6ca89a 100644
--- a/apps/site/content/site.ts
+++ b/apps/site/content/site.ts
@@ -37,7 +37,7 @@ export const site = {
"Install Sendo on Windows and confirm adb works from a new PowerShell window.",
"Enable ADB debugging on your Fire TV and approve the first connection prompt.",
"Add the Fire TV IP address in Sendo, then test the ADB connection.",
- "Connect Spotify with developer app credentials and the expected redirect URL.",
+ "Connect Spotify with the developer app client ID and registered loopback redirect URL using PKCE.",
"Select the exact Spotify Connect target device before running an action.",
"Run Start Spotify on TV and save it as a shortcut if you want.",
],
@@ -52,7 +52,7 @@ export const site = {
},
{
title: "Spotify auth fails",
- body: "Confirm the client ID, client secret, and redirect URL match the Spotify Developer app exactly.",
+ body: "Confirm the client ID and registered loopback redirect URL match the Spotify Developer app exactly. Sendo uses PKCE without a client secret.",
},
{
title: "Auth expired",
diff --git a/apps/tauri/src-tauri/src/commands.rs b/apps/tauri/src-tauri/src/commands.rs
index d82ded9..4ba3b87 100644
--- a/apps/tauri/src-tauri/src/commands.rs
+++ b/apps/tauri/src-tauri/src/commands.rs
@@ -9,6 +9,8 @@ use serde::Serialize;
use tauri::{async_runtime, command, AppHandle};
use tauri_plugin_autostart::ManagerExt;
+static SETTINGS_SAVE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
#[derive(Serialize)]
pub struct AppInfo {
version: String,
@@ -39,8 +41,26 @@ pub fn get_app_info(app: AppHandle) -> AppInfo {
#[command]
pub async fn save_settings(app: AppHandle, config: AppConfig) -> Result {
run_blocking(move || {
- sync_autostart_setting(&app, &config).map_err(|e| e.to_string())?;
- config.save().map_err(|e| e.to_string())?;
+ let _guard = SETTINGS_SAVE_LOCK.lock().map_err(|_| {
+ "Settings save lock is unavailable. Restart Sendo and retry.".to_string()
+ })?;
+ let manager = app.autolaunch();
+ let previous = manager.is_enabled().map_err(|e| {
+ format!("Could not read system autostart state. Settings were not saved: {e}")
+ })?;
+ save_with_autostart(
+ config.launch_on_startup,
+ previous,
+ |enabled| {
+ if enabled {
+ manager.enable()?;
+ } else {
+ manager.disable()?;
+ }
+ Ok(())
+ },
+ || config.save(),
+ )?;
Ok(config)
})
.await
@@ -56,6 +76,11 @@ pub async fn bindings_save(binding: Binding) -> Result {
run_blocking(move || bindings::save_binding(binding).map_err(|e| e.to_string())).await
}
+#[command]
+pub async fn bindings_reorder_favorites(ids: Vec) -> Result {
+ run_blocking(move || bindings::reorder_favorites(&ids).map_err(|e| format!("{e:#}"))).await
+}
+
#[command]
pub async fn bindings_delete(id: String) -> Result {
run_blocking(move || bindings::delete_binding(&id).map_err(|e| e.to_string())).await
@@ -84,7 +109,6 @@ pub async fn health_check() -> Result {
firetv_summary: firetv::status_summary(&config.firetv_ip),
spotify_summary: spotify::status_summary(
&config.spotify_client_id,
- &config.spotify_client_secret,
&config.spotify_redirect_url,
),
})
@@ -214,16 +238,33 @@ pub async fn spotify_toggle_tv() -> Result {
Ok(ActionResult { message })
}
-fn sync_autostart_setting(app: &AppHandle, config: &AppConfig) -> anyhow::Result<()> {
- let autostart_manager = app.autolaunch();
- let is_enabled = autostart_manager.is_enabled()?;
-
- match (config.launch_on_startup, is_enabled) {
- (true, false) => autostart_manager.enable()?,
- (false, true) => autostart_manager.disable()?,
- _ => {}
+// OS registration and file replacement cannot share a crash-atomic transaction.
+// Compensate reported failures here; apply_startup_preferences already attempts
+// to reconcile the OS registration with the persisted preference on next startup.
+fn save_with_autostart(
+ desired: bool,
+ previous: bool,
+ mut set_enabled: impl FnMut(bool) -> anyhow::Result<()>,
+ save: impl FnOnce() -> anyhow::Result<()>,
+) -> Result<(), String> {
+ let changed = desired != previous;
+ let result = (|| -> anyhow::Result<()> {
+ if changed {
+ set_enabled(desired)
+ .map_err(|e| anyhow::anyhow!("Could not synchronize system autostart: {e:#}"))?;
+ }
+ save().map_err(|e| anyhow::anyhow!("Could not persist settings: {e:#}"))
+ })();
+ if let Err(error) = result {
+ if changed {
+ if let Err(rollback) = set_enabled(previous) {
+ return Err(format!("Settings were not saved: {error:#}. Could not restore previous autostart state: {rollback:#}. Check system startup settings and retry."));
+ }
+ }
+ return Err(format!(
+ "Settings were not saved: {error:#}. Retry after resolving this error."
+ ));
}
-
Ok(())
}
@@ -276,3 +317,101 @@ pub async fn start_spotify_on_tv() -> Result {
Ok(ActionResult { message })
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::cell::{Cell, RefCell};
+
+ #[test]
+ fn settings_write_failure_restores_autostart() {
+ let enabled = Cell::new(false);
+ let changes = RefCell::new(Vec::new());
+ let result = save_with_autostart(
+ true,
+ false,
+ |value| {
+ enabled.set(value);
+ changes.borrow_mut().push(value);
+ Ok(())
+ },
+ || {
+ assert!(enabled.get());
+ anyhow::bail!("disk full")
+ },
+ );
+ assert!(!enabled.get());
+ assert_eq!(*changes.borrow(), vec![true, false]);
+ assert!(result.unwrap_err().contains("disk full"));
+ }
+
+ #[test]
+ fn autostart_failure_prevents_saving_and_reports_failed_compensation() {
+ let saved = Cell::new(false);
+ let changes = RefCell::new(Vec::new());
+ let result = save_with_autostart(
+ true,
+ false,
+ |value| {
+ changes.borrow_mut().push(value);
+ anyhow::bail!("startup registry denied")
+ },
+ || {
+ saved.set(true);
+ Ok(())
+ },
+ );
+ assert!(!saved.get());
+ assert_eq!(*changes.borrow(), vec![true, false]);
+ let error = result.unwrap_err();
+ assert!(error.contains("startup registry denied"));
+ assert!(error.contains("restore"));
+ }
+
+ #[test]
+ fn settings_failure_reports_both_save_and_rollback_errors() {
+ let result = save_with_autostart(
+ true,
+ false,
+ |value| {
+ if value {
+ Ok(())
+ } else {
+ anyhow::bail!("rollback denied")
+ }
+ },
+ || anyhow::bail!("disk full"),
+ );
+ let error = result.unwrap_err();
+ assert!(error.contains("disk full"));
+ assert!(error.contains("rollback denied"));
+ }
+
+ #[test]
+ fn settings_save_changes_autostart_only_when_needed() {
+ for (desired, current, expected) in [
+ (true, false, vec![true]),
+ (false, true, vec![false]),
+ (true, true, vec![]),
+ (false, false, vec![]),
+ ] {
+ let changes = RefCell::new(Vec::new());
+ let saved = Cell::new(false);
+ save_with_autostart(
+ desired,
+ current,
+ |value| {
+ changes.borrow_mut().push(value);
+ Ok(())
+ },
+ || {
+ saved.set(true);
+ Ok(())
+ },
+ )
+ .unwrap();
+ assert!(saved.get());
+ assert_eq!(*changes.borrow(), expected);
+ }
+ }
+}
diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs
index c6f874f..aa1b13d 100644
--- a/apps/tauri/src-tauri/src/lib.rs
+++ b/apps/tauri/src-tauri/src/lib.rs
@@ -37,6 +37,7 @@ pub fn run() {
commands::save_settings,
commands::bindings_list,
commands::bindings_save,
+ commands::bindings_reorder_favorites,
commands::bindings_delete,
commands::bindings_execute,
commands::health_check,
diff --git a/apps/tauri/src/api.ts b/apps/tauri/src/api.ts
index 954dca6..32999be 100644
--- a/apps/tauri/src/api.ts
+++ b/apps/tauri/src/api.ts
@@ -21,6 +21,7 @@ export const api = {
saveSettings: (config: AppConfig) => invoke("save_settings", { config }),
bindingsList: () => invoke("bindings_list"),
bindingsSave: (binding: Binding) => invoke("bindings_save", { binding }),
+ bindingsReorderFavorites: (ids: string[]) => invoke("bindings_reorder_favorites", { ids }),
bindingsDelete: (id: string) => invoke("bindings_delete", { id }),
bindingsExecute: (id: string) => invoke("bindings_execute", { id }),
healthCheck: () => invoke("health_check"),
diff --git a/apps/tauri/src/main.ts b/apps/tauri/src/main.ts
index aba734b..152ef3d 100644
--- a/apps/tauri/src/main.ts
+++ b/apps/tauri/src/main.ts
@@ -655,6 +655,7 @@ async function startSpotifyAuth() {
try {
await persistCurrentConfig();
const result = await api.spotifyStartAuth();
+ currentConfig = await api.getSettings();
currentSpotifyDebug = await api.spotifyDebugAuthFlow();
spotifyAuthUrl = result.url;
render();
@@ -1054,13 +1055,14 @@ function syncConfigFromInputs() {
currentConfig = {
firetv_ip: document.querySelector("#firetv-ip")?.value.trim() ?? currentConfig.firetv_ip,
spotify_client_id: document.querySelector("#spotify-client-id")?.value.trim() ?? currentConfig.spotify_client_id,
- spotify_client_secret: document.querySelector("#spotify-client-secret")?.value.trim() ?? currentConfig.spotify_client_secret,
spotify_redirect_url: document.querySelector("#spotify-redirect-url")?.value.trim() ?? currentConfig.spotify_redirect_url,
spotify_selected_device_id:
document.querySelector("#spotify-selected-device-id")?.value.trim() ??
currentConfig.spotify_selected_device_id,
spotify_target_hints: document.querySelector("#spotify-target-hints")?.value.trim() ?? currentConfig.spotify_target_hints,
spotify_auth_state: currentConfig.spotify_auth_state,
+ spotify_auth_verifier: currentConfig.spotify_auth_verifier,
+ spotify_auth_url: currentConfig.spotify_auth_url,
launch_on_startup:
document.querySelector("#launch-on-startup")?.checked ??
currentConfig.launch_on_startup,
@@ -1222,13 +1224,7 @@ async function reorderFavoriteBindings(sourceId: string, targetId: string) {
render();
try {
- let latestStore = { bindings: currentBindings };
- for (const binding of reorderedFavorites) {
- latestStore = await api.bindingsSave({
- ...binding,
- favorite_order: reorderedById.get(binding.id)!.favorite_order,
- });
- }
+ const latestStore = await api.bindingsReorderFavorites(reorderedFavorites.map((binding) => binding.id));
currentBindings = latestStore.bindings;
busy = false;
flash("Quick Access order updated.");
diff --git a/apps/tauri/src/pages/spotify.ts b/apps/tauri/src/pages/spotify.ts
index 7bb53f6..b57868d 100644
--- a/apps/tauri/src/pages/spotify.ts
+++ b/apps/tauri/src/pages/spotify.ts
@@ -66,7 +66,6 @@ export function renderSpotify({