From d947c2c522aac31790fec387c32063953a5f4142 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:40:35 +0300 Subject: [PATCH 1/6] fix(ui): make app info reactive in modals to resolve v? fallback --- src/lib/modules/about/ui/about_modal.svelte | 10 +++++-- src/lib/modules/app_info/index.ts | 29 +------------------ src/lib/modules/app_info/stores.svelte.ts | 28 ++++++++++++++++++ .../modules/updater/ui/update_banner.svelte | 10 +++++-- 4 files changed, 45 insertions(+), 32 deletions(-) create mode 100644 src/lib/modules/app_info/stores.svelte.ts diff --git a/src/lib/modules/about/ui/about_modal.svelte b/src/lib/modules/about/ui/about_modal.svelte index d103ee8..06f4e91 100644 --- a/src/lib/modules/about/ui/about_modal.svelte +++ b/src/lib/modules/about/ui/about_modal.svelte @@ -1,12 +1,18 @@ + +{#if active} +
+
+ + {badgeText} + + + {#if count > 1} +
+ + {currentIndex + 1}/{count} + +
+ {/if} + +
+ {active.title}: + {active.message} +
+
+ +
+ {#if active.type === 'both' || active.markdown} + + {/if} + + {#if active.action} + + {/if} + + {#if active.dismissible !== false} + + {/if} +
+
+{/if} diff --git a/src/lib/modules/announcements/ui/announcement_modal.svelte b/src/lib/modules/announcements/ui/announcement_modal.svelte new file mode 100644 index 0000000..08aea8c --- /dev/null +++ b/src/lib/modules/announcements/ui/announcement_modal.svelte @@ -0,0 +1,68 @@ + + +
+
+ {#if announcement.badge || announcement.severity !== 'info'} + + {announcement.badge ?? announcement.severity} + + {/if} +

{announcement.message}

+ + {#if announcement.markdown} + + {/if} +
+ +
+ + + {#if announcement.action} + + {/if} +
+
diff --git a/src/lib/modules/announcements/ui/index.ts b/src/lib/modules/announcements/ui/index.ts new file mode 100644 index 0000000..b607997 --- /dev/null +++ b/src/lib/modules/announcements/ui/index.ts @@ -0,0 +1,2 @@ +export { default as AnnouncementBanner } from './announcement_banner.svelte'; +export { default as AnnouncementModal } from './announcement_modal.svelte'; diff --git a/src/lib/modules/debug/ui/debug_panel.svelte b/src/lib/modules/debug/ui/debug_panel.svelte index f8a79ef..912615d 100644 --- a/src/lib/modules/debug/ui/debug_panel.svelte +++ b/src/lib/modules/debug/ui/debug_panel.svelte @@ -7,6 +7,7 @@ import { updaterStore, latestRelease } from '$lib/modules/updater'; import { getCachedAppInfo } from '$lib/modules/app_info'; import { Menu, MenuItem, MenuSection, MenuSeparator } from '$lib/modules/overlay/ui'; + import { announcementStore, runModalQueue } from '$lib/modules/announcements'; let open = $state(false); @@ -118,9 +119,119 @@ updaterStore.state = { phase: 'error', message: 'signature verification failed' }; } + function fakeRcBanner() { + announcementStore.bannerQueue = [ + { + id: 'notice-testing-v130-rc', + type: 'banner', + severity: 'info', + priority: 50, + badge: 'RC TEST', + title: 'Testing Splitwave 1.3.0 Release Candidate', + message: 'You are running Splitwave 1.3.0-rc.1. Please report any audio or UI feedback on GitHub.', + action: { + label: 'Report Issue', + url: 'https://github.com/Horuse/Splitwave/issues' + }, + dismissible: true, + filters: { + channels: ['rc'] + } + }, + ...announcementStore.bannerQueue.filter((b) => b.id !== 'notice-testing-v130-rc') + ]; + announcementStore.currentBannerIndex = 0; + } + + function fakeModalNotice() { + announcementStore.modalQueue = [ + { + id: 'notice-updater-resource-id-workaround', + type: 'modal', + severity: 'critical', + priority: 100, + badge: 'NOTICE', + title: 'Manual Update Required for v1.1.0', + message: 'In-app automatic updates are broken in v1.1.0. Please download the latest version manually once from splitwave.app or GitHub.', + markdown: + '### In-App Update Notice\n\nDue to an updater issue in version `1.1.0`, automatic updates fail with `The resource id is invalid`.\n\nTo update to the latest version, please download and install Splitwave manually once:\n- [Official Website](https://splitwave.app)\n- [GitHub Releases](https://github.com/Horuse/Splitwave/releases)\n\nYour existing pipelines, presets, and audio configuration will be preserved automatically.', + action: { + label: 'Download Latest', + url: 'https://splitwave.app' + }, + dismissible: true, + filters: { + versions: '1.1.0', + channels: ['stable', 'rc', 'beta'] + } + } + ]; + runModalQueue().catch(() => {}); + } + + function fakeQueueMultiple() { + announcementStore.bannerQueue = [ + { + id: 'banner-test-1', + type: 'banner', + severity: 'critical', + priority: 100, + badge: 'CRITICAL', + title: 'Buffer Underrun Detected', + message: 'Audio output buffer underflowed by 128 frames at 96kHz.', + dismissible: true + }, + { + id: 'banner-test-2', + type: 'banner', + severity: 'info', + priority: 50, + badge: 'RC TEST', + title: 'Testing Splitwave 1.3.0 Release Candidate', + message: 'Please report any edge connection or VST3 editor issues on GitHub.', + dismissible: true + } + ]; + announcementStore.currentBannerIndex = 0; + + announcementStore.modalQueue = [ + { + id: 'modal-test-1', + type: 'modal', + severity: 'warning', + title: 'Audio Buffer Optimization Notice', + message: 'Recommended buffer size was adjusted to minimize playback latency.', + markdown: + '### Notice #1\n\nThis tests the **sequential modal queue**. When you click Dismiss or Next, modal #2 opens automatically without overlapping popups.', + dismissible: true + }, + { + id: 'modal-test-2', + type: 'modal', + severity: 'info', + title: 'New Virtual Devices Available', + message: 'Splitwave virtual audio loopback drivers are ready for testing.', + markdown: '### Notice #2\n\nSequential queue verified! All modal announcements processed.', + dismissible: true + } + ]; + runModalQueue().catch(() => {}); + } + + function resetDismissedAnnouncements() { + if (typeof window !== 'undefined') { + window.localStorage.removeItem('announcements:dismissed'); + } + announcementStore.dismissed = {}; + announcementStore.bannerQueue = []; + announcementStore.modalQueue = []; + } + function clearAll() { errorStore.dismiss(); updaterStore.state = { phase: 'idle' }; + announcementStore.bannerQueue = []; + announcementStore.modalQueue = []; } @@ -141,6 +252,11 @@ + + + + + diff --git a/src/lib/modules/overlay/modal/manager.svelte.ts b/src/lib/modules/overlay/modal/manager.svelte.ts index 35fafec..b1f9f3e 100644 --- a/src/lib/modules/overlay/modal/manager.svelte.ts +++ b/src/lib/modules/overlay/modal/manager.svelte.ts @@ -9,6 +9,7 @@ export type ModalComponent> = Component void; [key: string]: unknown; } diff --git a/src/lib/modules/overlay/ui/modal/render.svelte b/src/lib/modules/overlay/ui/modal/render.svelte index 0636a11..8f4a10d 100644 --- a/src/lib/modules/overlay/ui/modal/render.svelte +++ b/src/lib/modules/overlay/ui/modal/render.svelte @@ -10,7 +10,10 @@ description={entry.params.description as string | undefined} size={entry.params.size as 'sm' | 'md' | 'lg' | 'xl' | undefined} canClose={entry.params.canClose !== false} - onClose={() => modalManager.close(entry.id)} + onClose={() => { + (entry.params.onClose as (() => void) | undefined)?.(); + modalManager.close(entry.id); + }} zIndex={entry.zIndex}> diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 14a65a2..968a137 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -18,6 +18,7 @@ import { logStore } from '$lib/modules/logs'; import { LogsModal } from '$lib/modules/logs/ui'; import { platform } from '@tauri-apps/plugin-os'; + import { initAnnouncements } from '$lib/modules/announcements'; const isDev = import.meta.env.DEV; @@ -106,6 +107,7 @@ await audioStore.autoActivateOnLaunch(); } catch {} pipelineStore.refresh().catch(() => {}); + initAnnouncements(); if (appSettings.checkUpdatesOnLaunch) checkForUpdates(true).catch(() => {}); listen('menu://action', (e) => handleMenu(e.payload)) .then((fn) => { diff --git a/tests/announcements_matcher.test.ts b/tests/announcements_matcher.test.ts new file mode 100644 index 0000000..6150216 --- /dev/null +++ b/tests/announcements_matcher.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'bun:test'; +import { detectChannel, matchesAnnouncement, sortAnnouncements } from '../src/lib/modules/announcements/matcher'; +import { parseAnnouncementsPayload, type Announcement, type EnvironmentContext } from '../src/lib/modules/announcements/types'; + +describe('detectChannel', () => { + it('detects rc channel', () => { + expect(detectChannel('1.3.0-rc.1')).toBe('rc'); + expect(detectChannel('v1.2.1-rc.2')).toBe('rc'); + }); + + it('detects beta channel', () => { + expect(detectChannel('1.3.0-beta.1')).toBe('beta'); + expect(detectChannel('v1.0.0-beta')).toBe('beta'); + }); + + it('detects alpha channel', () => { + expect(detectChannel('1.0.0-alpha.5')).toBe('alpha'); + }); + + it('detects stable channel', () => { + expect(detectChannel('1.2.0')).toBe('stable'); + expect(detectChannel('v1.3.0')).toBe('stable'); + }); +}); + +describe('matchesAnnouncement', () => { + const defaultContext: EnvironmentContext = { + version: '1.3.0-rc.1', + platform: 'macos', + arch: 'aarch64', + now: new Date('2026-09-11T20:00:00Z') + }; + + it('matches wildcard version and all platforms', () => { + const item: Announcement = { + id: 'general-notice', + type: 'banner', + severity: 'info', + title: 'Hello', + message: 'World' + }; + expect(matchesAnnouncement(item, defaultContext)).toBe(true); + }); + + it('matches semver range including pre-release', () => { + const item: Announcement = { + id: 'rc-notice', + type: 'banner', + severity: 'warning', + title: 'RC testing', + message: 'Test message', + filters: { + versions: '>=1.3.0-rc.0 <1.3.0' + } + }; + expect(matchesAnnouncement(item, defaultContext)).toBe(true); + + // Non-matching version + expect(matchesAnnouncement(item, { ...defaultContext, version: '1.2.0' })).toBe(false); + }); + + it('filters by exact version', () => { + const item: Announcement = { + id: 'exact-fix', + type: 'banner', + severity: 'critical', + title: 'Notice for 1.2.1', + message: 'Update info', + filters: { + versions: '1.2.1' + } + }; + expect(matchesAnnouncement(item, { ...defaultContext, version: '1.2.1' })).toBe(true); + expect(matchesAnnouncement(item, { ...defaultContext, version: '1.2.0' })).toBe(false); + }); + + it('filters by platform', () => { + const macOnly: Announcement = { + id: 'mac-only', + type: 'banner', + severity: 'info', + title: 'Mac notice', + message: 'Info', + filters: { + platforms: ['macos'] + } + }; + expect(matchesAnnouncement(macOnly, defaultContext)).toBe(true); + expect(matchesAnnouncement(macOnly, { ...defaultContext, platform: 'windows' })).toBe(false); + }); + + it('filters by pre-release channel', () => { + const rcOnly: Announcement = { + id: 'rc-survey', + type: 'banner', + severity: 'info', + title: 'RC Survey', + message: 'Survey', + filters: { + channels: ['rc'] + } + }; + expect(matchesAnnouncement(rcOnly, defaultContext)).toBe(true); + expect(matchesAnnouncement(rcOnly, { ...defaultContext, version: '1.4.0-rc.2' })).toBe(true); + expect(matchesAnnouncement(rcOnly, { ...defaultContext, version: '1.3.0-beta.1' })).toBe(false); + expect(matchesAnnouncement(rcOnly, { ...defaultContext, version: '1.2.0' })).toBe(false); + }); + + it('respects expiry timestamp', () => { + const expired: Announcement = { + id: 'expired-promo', + type: 'banner', + severity: 'info', + title: 'Old promo', + message: 'Expired', + filters: { + expiresAt: '2026-09-01T00:00:00Z' + } + }; + expect(matchesAnnouncement(expired, defaultContext)).toBe(false); + }); + + it('respects dismissal and updatedAt', () => { + const item: Announcement = { + id: 'dismiss-test', + type: 'banner', + severity: 'info', + title: 'Dismiss test', + message: 'Test' + }; + + const dismissed = { + 'dismiss-test': 1000 + }; + + // Dismissed and not updated + expect(matchesAnnouncement(item, defaultContext, dismissed)).toBe(false); + + // Updated after dismissal + const updatedItem: Announcement = { + ...item, + updatedAt: new Date(2000).toISOString() + }; + expect(matchesAnnouncement(updatedItem, defaultContext, dismissed)).toBe(true); + }); +}); + +describe('sortAnnouncements', () => { + it('sorts by priority first, then severity', () => { + const items: Announcement[] = [ + { id: '1', priority: 10, severity: 'info', type: 'banner', title: '1', message: '' }, + { id: '2', priority: 50, severity: 'warning', type: 'banner', title: '2', message: '' }, + { id: '3', priority: 50, severity: 'critical', type: 'banner', title: '3', message: '' }, + { id: '4', priority: 0, severity: 'critical', type: 'banner', title: '4', message: '' } + ]; + + const sorted = sortAnnouncements(items); + expect(sorted.map((s) => s.id)).toEqual(['3', '2', '1', '4']); + }); +}); + +describe('parseAnnouncementsPayload', () => { + it('keeps valid announcements and rejects malformed entries', () => { + const payload = parseAnnouncementsPayload({ + announcements: [ + { id: 'valid', type: 'banner', severity: 'info', title: 'Title', message: 'Message' }, + { id: 'invalid-type', type: 'toast', severity: 'info', title: 'Title', message: 'Message' }, + { id: 'missing-message', type: 'banner', severity: 'info', title: 'Title' } + ] + }); + + expect(payload?.announcements.map((item) => item.id)).toEqual(['valid']); + }); + + it('keeps only the first announcement with a duplicate id', () => { + const payload = parseAnnouncementsPayload({ + announcements: [ + { id: 'same', type: 'banner', severity: 'info', title: 'First', message: 'Message' }, + { id: 'same', type: 'modal', severity: 'warning', title: 'Second', message: 'Message' } + ] + }); + + expect(payload?.announcements).toHaveLength(1); + expect(payload?.announcements[0]?.title).toBe('First'); + }); + + it('rejects an invalid payload envelope', () => { + expect(parseAnnouncementsPayload({ announcements: null })).toBeNull(); + expect(parseAnnouncementsPayload([])).toBeNull(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 0b2d886..5fca590 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "types": ["bun-types"] } // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files diff --git a/vite.config.ts b/vite.config.ts index 2f6076b..26297e3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,6 @@ import { defineConfig } from 'vite'; import { sveltekit } from '@sveltejs/kit/vite'; import tailwindcss from '@tailwindcss/vite'; -// @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST; // https://vite.dev/config/ From aed43f78532e1695c40ddd4bf38a41fb9651887d Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:27:50 +0300 Subject: [PATCH 5/6] fix(waveform): sync node size with channel layout --- .../modules/flow/ui/effect/waveform.svelte | 47 +++++++++++++++---- src/lib/modules/flow/utils/nodes.ts | 2 +- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/lib/modules/flow/ui/effect/waveform.svelte b/src/lib/modules/flow/ui/effect/waveform.svelte index 3937186..09067ce 100644 --- a/src/lib/modules/flow/ui/effect/waveform.svelte +++ b/src/lib/modules/flow/ui/effect/waveform.svelte @@ -1,9 +1,9 @@ -
+
{#if !isPreview} - + {/if}
@@ -28,13 +57,13 @@
{#if !isPreview} - + {/if} -
- +
+
{#if !isPreview} - + {/if}
diff --git a/src/lib/modules/flow/utils/nodes.ts b/src/lib/modules/flow/utils/nodes.ts index ec17430..274ae06 100644 --- a/src/lib/modules/flow/utils/nodes.ts +++ b/src/lib/modules/flow/utils/nodes.ts @@ -205,7 +205,7 @@ export const registry: Record = { component: Waveform, icon: PulseIcon, defaultData: DEFAULT_NODE_DATA['waveform'], - defaultSize: { width: 200, height: 140 } + defaultSize: { width: 256, height: 130 } }), spectrum: entry<'spectrum'>({ kind: 'spectrum', From 44db04fdf587257a501c8df100296eac47ff892f Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:27:50 +0300 Subject: [PATCH 6/6] fix(waveform): limit buffer notice to recorder --- src/lib/components/waveform_scope.svelte | 6 ++++-- src/lib/modules/flow/ui/output/file_recording.svelte | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/components/waveform_scope.svelte b/src/lib/components/waveform_scope.svelte index 39d310f..2e0ef7f 100644 --- a/src/lib/components/waveform_scope.svelte +++ b/src/lib/components/waveform_scope.svelte @@ -19,7 +19,8 @@ // Encoder writes PCM (WAV/AIFF) — the disk-peak source; `null` falls // back to the path-extension heuristic. pcm = null, - maxChannels = null + maxChannels = null, + showBufferLimit = false }: { nodeId: string; height?: number; @@ -29,6 +30,7 @@ pcm?: boolean | null; // Caps displayed lanes; phantom multi lanes without a cable are dropped. maxChannels?: number | null; + showBufferLimit?: boolean; } = $props(); const SEG_FRAMES = 64; @@ -894,7 +896,7 @@ c.stroke(); c.restore(); - if (totalSegs > 0) { + if (showBufferLimit && totalSegs > 0) { c.save(); c.font = '6.5px monospace'; const line1 = 'Live view · Buffer limit'; diff --git a/src/lib/modules/flow/ui/output/file_recording.svelte b/src/lib/modules/flow/ui/output/file_recording.svelte index d3379b5..249d141 100644 --- a/src/lib/modules/flow/ui/output/file_recording.svelte +++ b/src/lib/modules/flow/ui/output/file_recording.svelte @@ -833,7 +833,8 @@ nodeId={id} filePath={data.filePath} pcm={data.format.kind === 'wav' || data.format.kind === 'aiff'} - maxChannels={waveformChannels} /> + maxChannels={waveformChannels} + showBufferLimit /> {/if}