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
12 changes: 8 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ name: Release
on:
pull_request:
push:
branches:
- main
tags:
- "v*"

Expand All @@ -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
Expand Down Expand Up @@ -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: |
Expand All @@ -76,6 +79,7 @@ jobs:
}
}
if ($failed) { exit 1 }
./scripts/Test-ValidationWorkflow.ps1
- name: Check frontend
working-directory: apps/tauri
shell: pwsh
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Repository instructions

Use `postigodev/` as the prefix for working branches, including future branches.
7 changes: 4 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions apps/site/app/install/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
];

Expand All @@ -40,7 +40,7 @@ export default async function InstallPage() {
</h1>
<p className="mt-6 max-w-2xl text-base leading-8 text-slate-300 md:text-lg">
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.
</p>
Expand Down Expand Up @@ -151,7 +151,7 @@ export default async function InstallPage() {
<p>
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.
</p>
<p>
Expand Down Expand Up @@ -196,7 +196,7 @@ export default async function InstallPage() {
03
</span>
<span>
Authenticate Spotify with the developer app credentials and
Authenticate Spotify using PKCE with the developer app client ID and
explicitly select a target.
</span>
</li>
Expand Down Expand Up @@ -341,8 +341,8 @@ function TroubleshootingBody({ title }: { title: string }) {
<li className="flex gap-3">
<span className="text-cyan-200">-</span>
<span>
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.
</span>
</li>
<li className="flex gap-3">
Expand Down
4 changes: 2 additions & 2 deletions apps/site/content/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
],
Expand All @@ -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",
Expand Down
163 changes: 151 additions & 12 deletions apps/tauri/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -39,8 +41,26 @@ pub fn get_app_info(app: AppHandle) -> AppInfo {
#[command]
pub async fn save_settings(app: AppHandle, config: AppConfig) -> Result<AppConfig, String> {
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
Expand All @@ -56,6 +76,11 @@ pub async fn bindings_save(binding: Binding) -> Result<BindingStore, String> {
run_blocking(move || bindings::save_binding(binding).map_err(|e| e.to_string())).await
}

#[command]
pub async fn bindings_reorder_favorites(ids: Vec<String>) -> Result<BindingStore, String> {
run_blocking(move || bindings::reorder_favorites(&ids).map_err(|e| format!("{e:#}"))).await
}

#[command]
pub async fn bindings_delete(id: String) -> Result<BindingStore, String> {
run_blocking(move || bindings::delete_binding(&id).map_err(|e| e.to_string())).await
Expand Down Expand Up @@ -84,7 +109,6 @@ pub async fn health_check() -> Result<HealthStatus, String> {
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,
),
})
Expand Down Expand Up @@ -214,16 +238,33 @@ pub async fn spotify_toggle_tv() -> Result<ActionResult, String> {
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(())
}

Expand Down Expand Up @@ -276,3 +317,101 @@ pub async fn start_spotify_on_tv() -> Result<ActionResult, String> {

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);
}
}
}
1 change: 1 addition & 0 deletions apps/tauri/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/tauri/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const api = {
saveSettings: (config: AppConfig) => invoke<AppConfig>("save_settings", { config }),
bindingsList: () => invoke<BindingStore>("bindings_list"),
bindingsSave: (binding: Binding) => invoke<BindingStore>("bindings_save", { binding }),
bindingsReorderFavorites: (ids: string[]) => invoke<BindingStore>("bindings_reorder_favorites", { ids }),
bindingsDelete: (id: string) => invoke<BindingStore>("bindings_delete", { id }),
bindingsExecute: (id: string) => invoke<ActionResult>("bindings_execute", { id }),
healthCheck: () => invoke<HealthStatus>("health_check"),
Expand Down
Loading