Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cce4489
Add Last.fm scrobbling
rianjs Aug 15, 2026
618114c
Fix Last.fm build error state
rianjs Aug 15, 2026
102c68c
Polish Last.fm preferences and persist window state
rianjs Aug 15, 2026
c177147
Normalize global UI typography
rianjs Aug 15, 2026
9c67a1d
Register window state before setup
rianjs Aug 15, 2026
536cc76
Exclude window state plugin from tests
rianjs Aug 16, 2026
4126a9a
Keep Last.fm keyring out of test binaries
rianjs Aug 16, 2026
7a5045f
Add diagnostic log viewer
rianjs Aug 16, 2026
b43b143
Validate diagnostic support email
rianjs Aug 16, 2026
cea82f1
Preserve per-source Library navigation
rianjs Aug 16, 2026
0df06d0
Give playlists independent column layouts
rianjs Aug 16, 2026
7af5046
Remove genre override markers
rianjs Aug 16, 2026
e333df4
Display missing music genres consistently
rianjs Aug 16, 2026
8b6a01c
Keep playback queues independent of navigation
rianjs Aug 16, 2026
146bf81
Correct legacy visual column order expectation
rianjs Aug 16, 2026
0d195ef
Correct legacy hidden column order expectation
rianjs Aug 16, 2026
c53db1d
Add library view state regression coverage
rianjs Aug 16, 2026
b7123ba
Format settings store test data
rianjs Aug 16, 2026
ab2bf56
fix: address review findings for library playback
rianjs Aug 16, 2026
16dda62
Harden diagnostic secret redaction
rianjs Aug 16, 2026
8bb1be0
Format review-fix Rust sources
rianjs Aug 16, 2026
52e9882
Fix remaining code review findings
rianjs Aug 16, 2026
b71bd09
Fix Last.fm regression test assertions
rianjs Aug 16, 2026
5d566cd
Make Last.fm session completion recoverable
rianjs Aug 16, 2026
ddb478c
Diagnose Windows test loader failure
rianjs Aug 16, 2026
1d41386
Identify missing Windows test symbols
rianjs Aug 16, 2026
5df24aa
Fix Windows diagnostic workflow syntax
rianjs Aug 16, 2026
d2bcc17
Fix Windows Tauri test manifest
rianjs Aug 16, 2026
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
8 changes: 7 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,13 @@ jobs:

- name: Build native bundle
working-directory: apps/desktop
run: npx tauri build --bundles ${{ matrix.bundle }}
env:
RETUNE_LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }}
RETUNE_LASTFM_SHARED_SECRET: ${{ secrets.LASTFM_API_SECRET }}
RETUNE_SUPPORT_EMAIL: ${{ vars.RETUNE_SUPPORT_EMAIL }}
run: |
node --input-type=module -e "for (const name of ['RETUNE_LASTFM_API_KEY', 'RETUNE_LASTFM_SHARED_SECRET']) if (!process.env[name]?.trim()) throw new Error(name + ' is required for release packaging')"
npx tauri build --bundles ${{ matrix.bundle }}

- name: Configure stable macOS signing
if: matrix.os == 'macos-15'
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/target
node_modules/
.env.lastfm.local
dist/
.DS_Store
28 changes: 28 additions & 0 deletions Cargo.lock

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

13 changes: 13 additions & 0 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ souvlaki = "0.8"
flate2 = "1"
chrono = "0.4"
log = "0.4"
md-5 = "0.10"
librespot-audio = { version = "0.8", default-features = false }
librespot-core = { version = "0.8", default-features = false, features = ["rustls-tls-native-roots"] }
librespot-protocol = { version = "0.8", default-features = false }
Expand All @@ -45,3 +46,15 @@ url = "2"

[dev-dependencies]
tempfile = "3"

[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", default-features = false, features = ["apple-native"] }

[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3", default-features = false, features = ["windows-native"] }

[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3", default-features = false, features = ["sync-secret-service", "crypto-rust"] }

[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-window-state = "2"
233 changes: 233 additions & 0 deletions apps/desktop/src-tauri/src/diagnostics.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
use std::{
fs, io,
path::{Path, PathBuf},
};

use serde::Serialize;
use tauri::Manager;
use tauri_plugin_opener::OpenerExt;

pub(crate) const LOG_TARGET: &str = "retune::diagnostics";
pub(crate) const SESSION_START_MARKER: &str = "retune.session.start";

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum DiagnosticLevel {
Info,
Warn,
Error,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct DiagnosticEntry {
pub(crate) date: String,
pub(crate) time: String,
pub(crate) level: DiagnosticLevel,
pub(crate) target: String,
pub(crate) message: String,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DiagnosticReport {
pub(crate) entries: Vec<DiagnosticEntry>,
pub(crate) email_available: bool,
}

fn bracketed_field(input: &str) -> Option<(&str, &str)> {
let input = input.strip_prefix('[')?;
let end = input.find(']')?;
Some((&input[..end], &input[end + 1..]))
}

pub(crate) fn parse_line(line: &str) -> Option<DiagnosticEntry> {
let (date, rest) = bracketed_field(line)?;
let (time, rest) = bracketed_field(rest)?;
let (level, rest) = bracketed_field(rest)?;
let (target, message) = bracketed_field(rest)?;
if date.is_empty() || time.is_empty() || target.is_empty() {
return None;
}
let message = message.strip_prefix(' ')?;
let level = match level {
"INFO" => DiagnosticLevel::Info,
"WARN" => DiagnosticLevel::Warn,
"ERROR" => DiagnosticLevel::Error,
_ => return None,
};
Some(DiagnosticEntry {
date: date.to_owned(),
time: time.to_owned(),
level,
target: target.to_owned(),
message: message.to_owned(),
})
}

pub(crate) fn current_session_entries(contents: &str) -> Vec<DiagnosticEntry> {
let mut entries = Vec::new();
let mut session_start = None;
for line in contents.lines() {
let Some(entry) = parse_line(line) else {
continue;
};
if entry.target == LOG_TARGET && entry.message == SESSION_START_MARKER {
session_start = Some(entries.len());
} else {
entries.push(entry);
}
}
let Some(session_start) = session_start else {
return Vec::new();
};
entries.into_iter().skip(session_start).collect()
}

pub(crate) fn log_file_path(log_dir: &Path, app_name: &str) -> PathBuf {
log_dir.join(app_name).with_extension("log")
}

pub(crate) fn read_current_session(path: &Path) -> io::Result<Vec<DiagnosticEntry>> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error),
};
Ok(current_session_entries(&contents))
}

pub(crate) fn support_email_from(value: Option<&str>) -> Option<&str> {
value.and_then(|value| {
let value = value.trim();
let (local, domain) = value.split_once('@')?;
(!local.is_empty()
&& !domain.is_empty()
&& !domain.contains('@')
&& !value
.bytes()
.any(|byte| byte.is_ascii_whitespace() || matches!(byte, b'?' | b'#' | b'&')))
.then_some(value)
})
}

fn support_email() -> Option<&'static str> {
support_email_from(option_env!("RETUNE_SUPPORT_EMAIL"))
}

fn mailto_url(email: &str, body: &str) -> String {
let query = url::form_urlencoded::Serializer::new(String::new())
.append_pair("subject", "Retune diagnostic report")
.append_pair("body", body)
.finish();
format!("mailto:{email}?{query}")
}

#[tauri::command]
pub(super) fn load_diagnostics(app: tauri::AppHandle) -> Result<DiagnosticReport, String> {
let log_dir = app
.path()
.app_log_dir()
.map_err(|error| format!("Could not locate the Retune log: {error}"))?;
let path = log_file_path(&log_dir, &app.package_info().name);
let entries = read_current_session(&path)
.map_err(|error| format!("Could not read the Retune log: {error}"))?;
Ok(DiagnosticReport {
entries,
email_available: support_email().is_some(),
})
}

#[tauri::command]
pub(super) fn email_diagnostics(app: tauri::AppHandle, body: String) -> Result<(), String> {
if body.trim().is_empty() {
return Err("There are no diagnostic problems to report.".into());
}
let email = support_email().ok_or_else(|| {
"Email support is unavailable in this build. Copy Logs and share the report instead."
.to_string()
})?;
app.opener()
.open_url(mailto_url(email, &body), None::<String>)
.map_err(|error| error.to_string())
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;

#[test]
fn parses_actual_bracketed_log_shape() {
for (name, expected) in [
("INFO", DiagnosticLevel::Info),
("WARN", DiagnosticLevel::Warn),
("ERROR", DiagnosticLevel::Error),
] {
let entry = parse_line(&format!(
"[2026-08-16][14:03:02][{name}][retune::sync] retrying"
))
.unwrap();
assert_eq!(entry.date, "2026-08-16");
assert_eq!(entry.time, "14:03:02");
assert_eq!(entry.level, expected);
assert_eq!(entry.target, "retune::sync");
assert_eq!(entry.message, "retrying");
}
}

#[test]
fn preserves_brackets_inside_messages() {
let entry = parse_line("[2026-08-16][14:03:02][ERROR][retune] failed [retry=2]").unwrap();
assert_eq!(entry.message, "failed [retry=2]");
}

#[test]
fn skips_malformed_and_unwanted_levels() {
assert!(parse_line("not a log").is_none());
assert!(parse_line("[date][time][INFO][target]missing-space").is_none());
assert!(parse_line("[date][time][DEBUG][target] debug").is_none());
assert!(parse_line("[date][time][INFO][] empty target").is_none());
}

#[test]
fn selects_entries_after_the_latest_session_marker() {
let contents = format!(
"[date][time][INFO][retune] old\n[date][time][INFO][{LOG_TARGET}] {SESSION_START_MARKER}\n[date][time][INFO][retune] first\n[date][time][INFO][{LOG_TARGET}] {SESSION_START_MARKER}\n[date][time][WARN][retune] latest"
);
let entries = current_session_entries(&contents);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].message, "latest");
}

#[test]
fn missing_log_is_empty_and_unreadable_log_is_an_error() {
let directory = tempdir().unwrap();
assert!(read_current_session(&directory.path().join("missing.log"))
.unwrap()
.is_empty());
assert!(read_current_session(directory.path()).is_err());
}

#[test]
fn support_email_helper_handles_configured_and_unconfigured_values() {
assert_eq!(support_email_from(None), None);
assert_eq!(support_email_from(Some(" ")), None);
assert_eq!(support_email_from(Some("not-an-email")), None);
assert_eq!(
support_email_from(Some("support@example.com?subject=bad")),
None
);
assert_eq!(
support_email_from(Some(" support@example.com ")),
Some("support@example.com")
);
}

#[test]
fn email_url_encodes_subject_and_report_body() {
let url = mailto_url("support@example.com", "[ERROR] failed & retry");
assert!(url.starts_with("mailto:support@example.com?"));
assert!(url.contains("subject=Retune+diagnostic+report"));
assert!(url.contains("body=%5BERROR%5D+failed+%26+retry"));
}
}
Loading
Loading