Skip to content
Open
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
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
tagName: ${{ github.ref_name }}
releaseName: Splitwave ${{ github.ref_name }}
releaseDraft: true
prerelease: false
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
args: ${{ matrix.args }}

linux:
Expand Down Expand Up @@ -106,7 +106,7 @@ jobs:
tagName: ${{ github.ref_name }}
releaseName: Splitwave ${{ github.ref_name }}
releaseDraft: true
prerelease: false
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
args: --bundles appimage,deb,rpm

# linuxdeploy bundles libwayland/libpipewire/libspa; on newer Mesa they
Expand Down Expand Up @@ -211,5 +211,5 @@ jobs:
tagName: ${{ github.ref_name }}
releaseName: Splitwave ${{ github.ref_name }}
releaseDraft: true
prerelease: false
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
args: --bundles nsis
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

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

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ tauri-plugin-os = "2"
tauri-plugin-autostart = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
url = "2"
thiserror = "1"
cpal = "0.15"
rtrb = "0.3"
Expand Down
24 changes: 17 additions & 7 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,8 +533,8 @@ pub async fn stop_pipeline(state: State<'_, AppState>, app: AppHandle) -> AppRes

// Updater errors serialize Display-only, hiding reqwest's cause; unwind source()+Debug.
#[tauri::command]
pub async fn diagnose_update_error(app: AppHandle) -> String {
let report = match configured_updater(&app) {
pub async fn diagnose_update_error(app: AppHandle, endpoint: Option<String>) -> String {
let report = match configured_updater(&app, endpoint.as_deref()) {
Ok(updater) => match updater.check().await {
Ok(Some(u)) => format!("check succeeded; update {} is available", u.version),
Ok(None) => "check succeeded; no update available".to_string(),
Expand Down Expand Up @@ -563,14 +563,21 @@ fn updater_tls_config() -> rustls::ClientConfig {
.with_no_client_auth()
}

fn configured_updater(app: &AppHandle) -> Result<tauri_plugin_updater::Updater, String> {
fn configured_updater(
app: &AppHandle,
endpoint: Option<&str>,
) -> Result<tauri_plugin_updater::Updater, String> {
use tauri_plugin_updater::UpdaterExt;
#[cfg(target_os = "linux")]
let builder = app
let mut builder = app
.updater_builder()
.configure_client(|b| b.tls_backend_preconfigured(updater_tls_config()));
#[cfg(not(target_os = "linux"))]
let builder = app.updater_builder();
let mut builder = app.updater_builder();
if let Some(ep) = endpoint {
let url = url::Url::parse(ep).map_err(|e| e.to_string())?;
builder = builder.endpoints(vec![url]).map_err(|e| e.to_string())?;
}
builder.build().map_err(|e| e.to_string())
}

Expand All @@ -588,9 +595,12 @@ pub struct UpdateMetadata {
/// Mirrors `plugin:updater|check` but with bundled roots wired into the HTTP
/// client; the plugin's own `check` command can't be configured.
#[tauri::command]
pub async fn check_for_updates(app: AppHandle) -> Result<Option<UpdateMetadata>, String> {
pub async fn check_for_updates(
app: AppHandle,
endpoint: Option<String>,
) -> Result<Option<UpdateMetadata>, String> {
use tauri::Manager;
let updater = configured_updater(&app)?;
let updater = configured_updater(&app, endpoint.as_deref())?;
let Some(update) = updater.check().await.map_err(|e| e.to_string())? else {
return Ok(None);
};
Expand Down
11 changes: 11 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,17 @@ pub fn run() {
}
}

if let Ok(dir) = handle.path().app_data_dir() {
let current = dir.join("pipelines.json");
let backup = dir.join("pipelines.backup-v1.json");
if current.exists() && !backup.exists() {
if let Ok(bytes) = std::fs::read(&current) {
let _ = std::fs::write(&backup, bytes);
info!(path = %backup.display(), "created automatic pre-1.2.0 pipeline backup");
}
}
}

#[cfg(target_os = "linux")]
if let Err(error) = audio::virtual_device::restore(&handle) {
tracing::error!(%error, "failed to restore PipeWire virtual devices");
Expand Down
19 changes: 19 additions & 0 deletions src/lib/components/layout/header.svelte
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { slide } from 'svelte/transition';
import { themeStore } from '$lib/modules/theme/stores';
import { platform } from '@tauri-apps/plugin-os';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { modalManager } from '$lib/modules/overlay/modal';
import { AboutModal } from '$lib/modules/about/ui';
import { checkForUpdates } from '$lib/modules/updater';
import { pipelineStore } from '$lib/modules/pipeline/stores.svelte';
import { audioStore } from '$lib/modules/audio/stores.svelte';
import { Popover } from '$lib/modules/overlay/ui';

interface Props {
Expand Down Expand Up @@ -98,3 +100,20 @@
{/if}
</div>
</header>

{#if audioStore.safeMode}
<div
transition:slide={{ duration: 150 }}
class="relative z-40 flex w-full items-center justify-between border-b border-amber-500/20 bg-amber-500/10 px-4 py-1.5 text-xs text-amber-600 dark:text-amber-400">
<div class="flex items-center gap-2">
<span class="rounded bg-amber-500/20 px-1.5 py-0.5 font-bold uppercase tracking-wider text-[10px]">Safe Mode</span>
<span>Auto-starting audio pipeline was skipped because Splitwave crashed last time.</span>
</div>
<button
type="button"
class="btn-warning px-3 py-0.5 text-xs"
onclick={() => (audioStore.safeMode = false)}>
Dismiss
</button>
</div>
{/if}
19 changes: 19 additions & 0 deletions src/lib/modules/audio/stores.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { UnlistenFn } from '@tauri-apps/api/event';
import { browser } from '$app/environment';
import toast from 'svelte-french-toast';
import { open, save } from '@tauri-apps/plugin-dialog';
import { methods } from './methods';
Expand All @@ -8,6 +9,7 @@ import { pipelineStore } from '$lib/modules/pipeline/stores.svelte';
import { isFromFuture } from '$lib/modules/pipeline/migrations';
import type { FileRecordingNodeData, PipelineNode, RecordingFormat } from '$lib/modules/pipeline/types';
import { appSettings } from '$lib/modules/settings/stores.svelte';
import { errorStore } from '$lib/modules/error';

// Mirrors `extension()` in the File Recording node: the dialog filter must
// match the encoder the node will actually write.
Expand Down Expand Up @@ -37,6 +39,7 @@ class AudioStore {
pendingRetryPipelineId = $state<string | null>(null);
pendingNodeIds = $state<Set<string>>(new Set());
missingFilePaths = $state<Set<string>>(new Set());
safeMode = $state(false);

private lastGraph: StartPipelinePayload | null = null;
private fullGraph: StartPipelinePayload | null = null;
Expand Down Expand Up @@ -189,6 +192,7 @@ class AudioStore {
}

async activatePipeline(pipelineId: string, graph: StartPipelinePayload): Promise<void> {
this.safeMode = false;
this.lastGraph = graph;
this.fullGraph = graph;
const excluded = appSettings.keepRunningOnDisconnect ? await this.unresolvedInputIds(graph) : new Set<string>();
Expand Down Expand Up @@ -292,6 +296,16 @@ class AudioStore {
* is left out of the initial start and reconnected later by the polling
* loop once it becomes available. */
async autoActivateOnLaunch(): Promise<void> {
const crashGuard = browser && window.localStorage.getItem('splitwave:boot_audio_crash_guard') === 'true';
if (errorStore.hadPreviousCrash || crashGuard) {
this.safeMode = true;
if (browser) window.localStorage.removeItem('splitwave:boot_audio_crash_guard');
this.reportError(
new Error('Safe Mode: Auto-starting previous pipeline was skipped because Splitwave crashed last time.')
);
return;
}

const id = await pipelineMethods.getActivePipelineId().catch(() => null);
if (!id) return;
const p = await pipelineMethods.get(id).catch(() => null);
Expand All @@ -301,12 +315,17 @@ class AudioStore {
const excluded = appSettings.keepRunningOnDisconnect ? await this.unresolvedInputIds(full) : new Set<string>();
this.pendingNodeIds = excluded;
const reduced = this.buildReducedGraph(full, excluded);
if (browser) window.localStorage.setItem('splitwave:boot_audio_crash_guard', 'true');
try {
await methods.startPipeline(reduced);
} catch (e) {
if (browser) window.localStorage.removeItem('splitwave:boot_audio_crash_guard');
this.reportError(e);
return;
}
setTimeout(() => {
if (browser) window.localStorage.removeItem('splitwave:boot_audio_crash_guard');
}, 4000);
this.lastGraph = reduced;
this.runningPipelineId = id;
if (excluded.size > 0) {
Expand Down
38 changes: 37 additions & 1 deletion src/lib/modules/debug/ui/debug_panel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { invoke } from '@tauri-apps/api/core';
import type { Update } from '@tauri-apps/plugin-updater';
import { errorStore } from '$lib/modules/error';
import { audioStore } from '$lib/modules/audio/stores.svelte';
import { updaterStore, latestRelease } from '$lib/modules/updater';
import { getCachedAppInfo } from '$lib/modules/app_info';
import { Menu, MenuItem, MenuSection, MenuSeparator } from '$lib/modules/overlay/ui';
Expand Down Expand Up @@ -50,6 +51,21 @@
});
}

async function fakeSafeMode() {
if (audioStore.isRunning) {
await audioStore.deactivatePipeline().catch(() => {});
}
audioStore.safeMode = true;
errorStore.report({
source: 'nativeCrash',
message: 'Native crash: SIGSEGV (Process crashed during audio startup)',
stack: 'The process terminated before a Rust backtrace could be captured. Use the OS crash dump for the native stack.',
thread: '<native>',
at: Date.now(),
previousRun: true
});
}

// Uses the real latest GitHub release so the modal shows notes of the shape
// users actually get.
async function fakeUpdateAvailable() {
Expand All @@ -70,6 +86,24 @@
};
}

async function fakeBetaUpdateAvailable() {
const release = await latestRelease();
const stub = {
version: '1.2.0-rc.1',
currentVersion: getCachedAppInfo()?.appVersion ?? '1.1.0',
date: new Date().toISOString(),
downloadAndInstall: async () => {},
download: async () => {},
install: async () => {},
close: async () => {}
} as unknown as Update;
updaterStore.state = {
phase: 'available',
update: stub,
notes: release?.notes ?? '### Pre-release v1.2.0-rc.1\n\n- Safe mode on crash\n- Automatic pipeline backup\n- Pre-release beta channel'
};
}

function fakeDownloading() {
const stub = { version: '0.2.0' } as unknown as Update;
updaterStore.state = {
Expand All @@ -95,14 +129,16 @@
<div transition:fly={{ duration: 200, y: 5 }}>
<Menu>
<MenuSection label="Errors" />
<MenuItem label="Rust panic" onclick={fakeRustPanic} />
<MenuItem label="Rust panic (preview)" onclick={fakeRustPanic} />
<MenuItem label="Real crash (panic)" onclick={realRustCrash} />
<MenuItem label="Native crash (process)" onclick={nativeCrash} />
<MenuItem label="Unexpected exit (process)" onclick={unexpectedExit} />
<MenuItem label="JS error" onclick={fakeJsError} />
<MenuItem label="Promise rejection" onclick={fakePromiseRejection} />
<MenuItem label="Safe Mode (simulate)" onclick={fakeSafeMode} />
<MenuSection label="Updater" />
<MenuItem label="Update available" onclick={fakeUpdateAvailable} />
<MenuItem label="Pre-release update available" onclick={fakeBetaUpdateAvailable} />
<MenuItem label="Downloading 30%" onclick={fakeDownloading} />
<MenuItem label="Update error" onclick={fakeUpdateError} />
<MenuSeparator />
Expand Down
27 changes: 13 additions & 14 deletions src/lib/modules/error/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,19 @@ export async function installErrorHandlers(): Promise<void> {

// Fatal native failures cannot reach the live webview; replay every report
// persisted by the backend during the previous run.
invoke<CrashPayload[]>('take_crash_reports')
.then((reports) => {
for (const r of reports) {
errorStore.report({
source: r.kind ?? 'rustPanic',
message: r.message,
stack: r.backtrace,
thread: r.thread,
at: r.ts ?? Date.now(),
previousRun: true
});
}
})
.catch(() => {});
try {
const reports = await invoke<CrashPayload[]>('take_crash_reports');
for (const r of reports) {
errorStore.report({
source: r.kind ?? 'rustPanic',
message: r.message,
stack: r.backtrace,
thread: r.thread,
at: r.ts ?? Date.now(),
previousRun: true
});
}
} catch {}

window.addEventListener('error', (e) => {
errorStore.report({
Expand Down
4 changes: 4 additions & 0 deletions src/lib/modules/error/stores.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@ export interface ErrorEntry {

class ErrorStore {
current = $state<ErrorEntry | null>(null);
hadPreviousCrash = $state(false);

report(entry: ErrorEntry): void {
if (entry.previousRun) {
this.hadPreviousCrash = true;
}
this.current = entry;
}

Expand Down
22 changes: 19 additions & 3 deletions src/lib/modules/error/ui/error_modal.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { openUrl } from '@tauri-apps/plugin-opener';
import { errorStore } from '../stores.svelte';
import { audioStore } from '$lib/modules/audio/stores.svelte';
import { formatAppInfo, getCachedAppInfo } from '$lib/modules/app_info';
import { ModalShell } from '$lib/modules/overlay/ui/modal';
import CopyButton from '$lib/components/copy_button.svelte';
Expand Down Expand Up @@ -81,12 +82,27 @@
titleClass={current.previousRun ? 'text-sm font-semibold text-amber-500' : 'text-sm font-semibold text-red-500'}
onClose={dismiss}>
{#snippet badge()}
<span class="rounded-md bg-neutral-200 px-2 py-0.5 font-mono text-[10px] text-neutral-1000">
{sourceLabel(current.source)}
</span>
<div class="flex items-center gap-1.5">
<span class="rounded-md bg-neutral-200 px-2 py-0.5 font-mono text-[10px] text-neutral-1000">
{sourceLabel(current.source)}
</span>
{#if current.previousRun || audioStore.safeMode}
<span class="rounded-md bg-amber-500/20 px-1.5 py-0.5 font-sans text-[10px] font-semibold text-amber-600 dark:text-amber-400">
SAFE MODE
</span>
{/if}
</div>
{/snippet}

<div class="flex flex-col gap-3 px-5 py-4">
{#if current.previousRun || audioStore.safeMode}
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-600 dark:text-amber-400">
<div class="font-semibold">Safe Mode is active</div>
<div class="mt-0.5 text-[11px] opacity-90">
Automatic audio pipeline startup was skipped to prevent a crash loop. You can safely inspect or edit your pipelines and start them manually when ready.
</div>
</div>
{/if}
<p class="text-xs text-neutral-900">
{#if current.previousRun}
The app closed unexpectedly during your previous session. Reporting this helps us fix it.
Expand Down
13 changes: 12 additions & 1 deletion src/lib/modules/pipeline/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,18 @@ export const methods = {

async save(p: Pipeline): Promise<void> {
const clean = pruneDanglingEdges(p);
await store.set(KEY_PREFIX + p.id, { ...clean, version: PIPELINE_VERSION });
const nodes = clean.nodes.map((n) => {
if (n.kind !== 'fileRecording') return n;
const data = n.data as Record<string, unknown>;
return {
...n,
data: {
...data,
allowOverwrite: data.mode === 'overwrite'
}
};
});
await store.set(KEY_PREFIX + p.id, { ...clean, nodes, version: PIPELINE_VERSION });
await store.save();
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function migrateFileRecordingMode(pipeline: Pipeline): Pipeline {
if (n.kind !== 'fileRecording') return n;
const data = withDefaults(n.kind, n.data) as Record<string, unknown>;
if (data.allowOverwrite === true) data.mode = 'overwrite';
delete data.allowOverwrite;
data.allowOverwrite = data.mode === 'overwrite';
return { ...n, data };
})
};
Expand Down
Loading
Loading