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
1 change: 0 additions & 1 deletion .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ tone_instructions: >-
utilisateur, les problèmes de sécurité, les erreurs de logique et les
oublis de tests/validation. Évite les remarques purement cosmétiques.
early_access: true

reviews:
profile: assertive
request_changes_workflow: true
Expand Down
141 changes: 69 additions & 72 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Actions):
| `WINGET_PAT` | GitHub Personal Access Token (classic, `public_repo` scope) the `.github/workflows/winget.yml` action uses to fork microsoft/winget-pkgs and open the PR with the new manifest |
| `COPR_LOGIN` | `login` field from <https://copr.fedorainfracloud.org/api/> — `.github/workflows/copr.yml` uses it to authenticate to Fedora COPR via `copr-cli` |
| `COPR_TOKEN` | `token` field from the same COPR API page (paired with `COPR_LOGIN`). Token lifetime is 6 months — rotate when builds start returning `401 Unauthorized` |
| `BUILDKITE_PACKAGES_TOKEN` | Buildkite API token (`read_packages` + `write_packages`) for `.github/workflows/apt-publish.yml` to push the `.deb` to the `instazdll/waveflow` registry |
| `BUILDKITE_PACKAGES_TOKEN` | Buildkite API token (`read_packages` + `write_packages`) for `.github/workflows/apt-publish.yml` to push the `.deb` to the `instazdll/waveflow` registry |

The AUR package itself (`waveflow-bin`) needs a one-off manual setup
on the maintainer's box — see [`packaging/aur/README.md`](../packaging/aur/README.md).
Expand Down
2 changes: 1 addition & 1 deletion public/splash.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
padding: 0;
height: 100%;
overflow: hidden;
background: transparent;
background: #121212;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif;
Expand Down
7 changes: 6 additions & 1 deletion src-tauri/src/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,12 @@ pub async fn write_config(
("backup.retention", retention.to_string(), "int"),
(
"backup.include_metadata_artwork",
if include_metadata_artwork { "true" } else { "false" }.to_string(),
if include_metadata_artwork {
"true"
} else {
"false"
}
.to_string(),
"bool",
),
] {
Expand Down
13 changes: 6 additions & 7 deletions src-tauri/src/commands/profile_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,13 @@ pub async fn export_profile(
/// Read the `backup.include_metadata_artwork` app setting. Defaults to
/// `true` so a fresh install + first manual export produces a complete
/// archive without the user having to opt in.
pub(crate) async fn read_include_metadata_artwork(
app_db: &SqlitePool,
) -> AppResult<bool> {
pub(crate) async fn read_include_metadata_artwork(app_db: &SqlitePool) -> AppResult<bool> {
let row: Option<String> = sqlx::query_scalar(
"SELECT value FROM app_setting WHERE key = 'backup.include_metadata_artwork'",
)
.fetch_optional(app_db)
.await?;
Ok(row
.map(|v| v == "true" || v == "1")
.unwrap_or(true))
Ok(row.map(|v| v == "true" || v == "1").unwrap_or(true))
}

/// Import a `.waveflow` archive as a brand-new profile. The new
Expand Down Expand Up @@ -353,7 +349,10 @@ pub(crate) fn write_archive(
let rel = entry_path
.strip_prefix(meta_dir)
.map_err(|e| AppError::Other(format!("metadata_artwork rel: {e}")))?;
let zip_name = format!("metadata_artwork/{}", rel.to_string_lossy().replace('\\', "/"));
let zip_name = format!(
"metadata_artwork/{}",
rel.to_string_lossy().replace('\\', "/")
);
zip.start_file(&zip_name, opts)?;
let mut src = File::open(entry_path)?;
std::io::copy(&mut src, &mut zip)?;
Expand Down
100 changes: 99 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ mod watcher;

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;

use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, WindowEvent,
AppHandle, Listener, Manager, WindowEvent,
};

use audio::{AudioCmd, AudioEngine};
Expand Down Expand Up @@ -364,6 +365,68 @@ pub fn run() {
})
.build(app)?;

// Splash → main handoff.
//
// The frontend emits `app://ready` once the React root has
// committed its first useful paint (see src/main.tsx). When
// we get that event we close the splash and reveal the
// main window from native code — more reliable than the
// previous IPC dance, which on Linux WebKitGTK 2.52 raced
// against the heavy first-launch init (migrations + DB
// pool + library scan) and left the splash hanging
// forever (issue #42).
//
// A 15 s safety-net timer force-reveals the main window if
// `app://ready` never fires — guards against a frontend
// crash leaving the user stuck on an eternal splash.
let handoff_done = Arc::new(AtomicBool::new(false));
let handoff_handle = app.handle().clone();
let handoff_done_for_event = handoff_done.clone();
app.listen("app://ready", move |_event| {
if handoff_done_for_event.swap(true, Ordering::SeqCst) {
return;
}
// Reset the flag if the reveal fails so the fallback
// timer (or a subsequent `app://ready` re-emission) can
// retry — otherwise a transient main.show() / splash.close()
// failure would leave the user stuck on an eternal splash
// with no recovery path.
if !reveal_main_close_splash(&handoff_handle) {
handoff_done_for_event.store(false, Ordering::SeqCst);
}
});

let fallback_handle = app.handle().clone();
let fallback_done = handoff_done.clone();
tauri::async_runtime::spawn(async move {
// First attempt after 15 s; subsequent retries every
// 250 ms up to 10 total attempts. Bounded so a
// permanently missing main window doesn't spin forever
// (the warn! log surfaces it instead). The retry exists
// because `ReadySignal` only emits `app://ready` once
// at mount — if we lost the race with that single
// event AND the first reveal failed, without a retry
// the user would be stuck on the splash.
tokio::time::sleep(Duration::from_secs(15)).await;
for attempt in 0..10 {
if fallback_done.swap(true, Ordering::SeqCst) {
return;
}
tracing::warn!(
attempt,
"splash handoff fallback: `app://ready` never fired in time, force-revealing main window"
);
if reveal_main_close_splash(&fallback_handle) {
return;
}
fallback_done.store(false, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(250)).await;
}
tracing::error!(
"splash handoff fallback: exhausted 10 reveal attempts, user is likely stuck on splash"
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Ok(())
})
.invoke_handler(tauri::generate_handler![
Expand Down Expand Up @@ -647,6 +710,41 @@ fn show_main_window(app: &AppHandle) {
}
}

/// Reveal the main window and close the splash, in that order.
///
/// Same ordering rule as the old frontend version: show main first,
/// then close splash, so there is never a moment where the desktop is
/// visible between the two on a multi-monitor / compositing setup.
///
/// Returns `true` only when *both* operations succeed (main shown +
/// splash closed, or splash already absent). On any failure, returns
/// `false` so the caller can clear its "done" flag and let the other
/// path (event listener or fallback timer) retry — without this,
/// a transient failure would leave the user stuck on an eternal
/// splash.
fn reveal_main_close_splash(app: &AppHandle) -> bool {
// Bail out *before* touching the splash if the main window isn't
// available or refuses to show — otherwise we'd close the only
// visible window the user has and leave them staring at the
// desktop with no way back into the app until they re-launch.
let Some(main) = app.get_webview_window("main") else {
tracing::warn!("splash handoff: main window missing at reveal time");
return false;
};
if let Err(err) = main.show() {
tracing::warn!(?err, "splash handoff: main.show failed");
return false;
}
let _ = main.set_focus();
if let Some(splash) = app.get_webview_window("splashscreen") {
if let Err(err) = splash.close() {
tracing::warn!(?err, "splash handoff: splash.close failed");
return false;
}
}
true
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Toggle Pause / Resume from the tray. Looks at the engine's current
/// state (atomic, no async needed) so the menu item works as a single
/// "Lecture / Pause" entry instead of two stateful labels we'd have to
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@
"center": true,
"resizable": false,
"decorations": false,
"transparent": true,
"alwaysOnTop": true,
"skipTaskbar": true,
"focus": false,
"shadow": false
"shadow": false,
"backgroundColor": "#121212"
}
],
"security": {
Expand Down
31 changes: 31 additions & 0 deletions src/components/common/ReadySignal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useEffect, type ReactNode } from "react";
import { emit } from "@tauri-apps/api/event";

/**
* Emits `app://ready` once after the first React commit so the Rust
* backend can reveal the main window and close the splash from native
* code (see `reveal_main_close_splash` in src-tauri/src/lib.rs).
*
* We rely on a `useEffect` rather than a `requestAnimationFrame` dance
* because WebKitGTK 2.52 suspends rAF callbacks while a window is
* `visible: false` — rAF would never fire until the backend reveals
* the window, deadlocking the handoff until the 15 s safety-net timer
* trips. `useEffect` runs after React commits, which is the actual
* guarantee we care about (DOM is populated before reveal); the
* compositor will paint the first frame as part of the reveal itself,
* so we don't need to observe a paint to avoid a flash.
*
* Lives in its own file rather than in `main.tsx` so it satisfies
* the React Fast Refresh constraint ("a file must only export
* components"). Bundle entry points like `main.tsx` have non-component
* side effects (root.render, i18n init) that prevent HMR from
* extracting the component cleanly.
*/
export function ReadySignal({ children }: { children: ReactNode }) {
useEffect(() => {
void emit("app://ready").catch((err) => {
console.error("[ReadySignal] emit(app://ready) failed", err);
});
}, []);
return <>{children}</>;
}
4 changes: 1 addition & 3 deletions src/components/views/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -615,9 +615,7 @@ export function SettingsView({ onNavigate }: SettingsViewProps) {
setCoverResultMsg(null);
try {
const fetched = await batchFetchMissingAlbumCovers();
setCoverResultMsg(
t("library.fetchCoversResult", { count: fetched }),
);
setCoverResultMsg(t("library.fetchCoversResult", { count: fetched }));
} catch (err) {
console.error("[SettingsView] fetch missing covers failed", err);
setCoverResultMsg(t("library.fetchCoversFailed"));
Expand Down
105 changes: 54 additions & 51 deletions src/contexts/ThemeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,63 +34,66 @@ const writeStoredTheme = (isDark: boolean) => {
export function ThemeProvider({ children }: { children: ReactNode }) {
const [isDark, setIsDark] = useState<boolean>(readStoredTheme);

const toggleTheme = useCallback((event?: ReactMouseEvent) => {
let nextValue = false;
const flipTheme = () =>
setIsDark((prev) => {
nextValue = !prev;
return nextValue;
});
const toggleTheme = useCallback(
(event?: ReactMouseEvent) => {
let nextValue = false;
const flipTheme = () =>
setIsDark((prev) => {
nextValue = !prev;
return nextValue;
});

// Persist BEFORE triggering any animation. Some Linux WebKitGTK builds
// crash the webview during startViewTransition on certain GPU/Wayland
// stacks (issue #34) — writing first guarantees the next launch picks
// up the new theme even if this transition kills the process.
const persistNext = (value: boolean) => writeStoredTheme(value);
// Persist BEFORE triggering any animation. Some Linux WebKitGTK builds
// crash the webview during startViewTransition on certain GPU/Wayland
// stacks (issue #34) — writing first guarantees the next launch picks
// up the new theme even if this transition kills the process.
const persistNext = (value: boolean) => writeStoredTheme(value);

// Fallback: no View Transitions API support → instant swap
if (typeof document === "undefined" || !document.startViewTransition) {
flipTheme();
persistNext(nextValue);
return;
}
// Fallback: no View Transitions API support → instant swap
if (typeof document === "undefined" || !document.startViewTransition) {
flipTheme();
persistNext(nextValue);
return;
}

// Compute & persist the future value before the animation starts so the
// write lands even if the compositor crashes mid-transition.
persistNext(!isDark);
// Compute & persist the future value before the animation starts so the
// write lands even if the compositor crashes mid-transition.
persistNext(!isDark);

const transition = document.startViewTransition(flipTheme);
const transition = document.startViewTransition(flipTheme);

// Radial reveal from the click point
if (event) {
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
);
// Radial reveal from the click point
if (event) {
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
);

transition.ready
.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 600,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
},
);
})
.catch(() => {
// Animation failed — the theme has still toggled via flipTheme()
});
}
}, [isDark]);
transition.ready
.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 600,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
},
);
})
.catch(() => {
// Animation failed — the theme has still toggled via flipTheme()
});
}
},
[isDark],
);

return (
<ThemeContext.Provider value={{ isDark, toggleTheme }}>
Expand Down
Loading
Loading