diff --git a/AGENTS.md b/AGENTS.md index 1272c04..c5da482 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ the behavior you are changing: | Completed SOLID audit evidence | [SOLID audit record](docs/SOLID.md) | | Completed Rust audit evidence | [Rust audit record](docs/rust.md) | | Current responsiveness work | [Responsiveness plan](docs/plans/responsiveness-burndown.md) | +| Staged Tauri performance experiments | [Experiment measurements](docs/performance/tauri-experiments/README.md) | | Responsiveness audit evidence | [Rust performance audit](docs/rust-slop-performance-audit.md), [Last.fm interaction audit](docs/lastfm-interaction-audit.md), [type-ahead audit](docs/typeahead-performance-audit.md) | ## Invariants diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index a547bf3..93f0698 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +performance-dist *.local # Editor directories and files diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8d75017..6509e3c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint --deny-warnings", - "test": "node --test --experimental-strip-types test/ui.test.ts test/gateways.test.ts && vitest run test/interactions.test.tsx", + "test": "node --test --experimental-strip-types test/ui.test.ts test/gateways.test.ts && vitest run test/interactions.test.tsx test/mainEntry.test.tsx test/importQueue.test.ts", "preview": "vite preview" }, "dependencies": { diff --git a/apps/desktop/performance/check-startup.mjs b/apps/desktop/performance/check-startup.mjs new file mode 100644 index 0000000..81c45af --- /dev/null +++ b/apps/desktop/performance/check-startup.mjs @@ -0,0 +1,15 @@ +// Check browser resource observations, including Vite's conditional-preload regression. +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +const report = JSON.parse(await readFile(process.argv[2], 'utf8')) +assert.equal(report.commit, process.argv[3], 'Use observations from the candidate being reviewed') +for (const label of ['main', 'lastfm-importer']) { + const samples = report.samples.filter(sample => sample.window === label) + assert(samples.length >= 3) + for (const sample of samples) { + const unwanted = label === 'main' ? 'LastFmImporter-' : 'App-' + assert(!sample.resources.some(resource => resource.name.startsWith(unwanted)), `${label} loaded the other window`) + if (label === 'lastfm-importer') assert(sample.resources.some(resource => resource.name.startsWith('LastFmImporter-') && resource.name.endsWith('.css')), 'Importer CSS was not loaded') + } +} +console.log('Both window entries load only their own view and required importer CSS') diff --git a/apps/desktop/performance/importer.html b/apps/desktop/performance/importer.html new file mode 100644 index 0000000..f725bf6 --- /dev/null +++ b/apps/desktop/performance/importer.html @@ -0,0 +1 @@ +Importer queue benchmark
diff --git a/apps/desktop/performance/importer.tsx b/apps/desktop/performance/importer.tsx new file mode 100644 index 0000000..d8dcf9f --- /dev/null +++ b/apps/desktop/performance/importer.tsx @@ -0,0 +1,27 @@ +import { Profiler } from 'react' +import { createRoot } from 'react-dom/client' +import LastFmImporter from '../src/LastFmImporter.tsx' +import { invalidate, queueWire } from './native.ts' +import '../src/index.css' +import '../src/App.css' +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) +let measuring = false, renderMs = 0, commits = 0 +const results: unknown[] = [] +const panel = document.createElement('aside') +panel.style.cssText = 'position:fixed;bottom:0;right:0;z-index:10000;background:white;color:black;max-height:160px;overflow:auto;font:12px monospace' +const button = document.createElement('button'); button.textContent = 'Measure importer refresh burst' +const output = document.createElement('pre'); output.id = 'importer-results' +panel.append(button, output); document.body.append(panel) +button.onclick = async () => { + button.disabled = true + const calls = queueWire.calls, bytes = queueWire.bytes + renderMs = 0; commits = 0; measuring = true + const start = performance.now() + for (let i = 0; i < 6; i++) { invalidate('lastfm-import-changed'); await wait(1) } + while (queueWire.inFlight) await wait(1) + await new Promise(requestAnimationFrame); await new Promise(requestAnimationFrame) + measuring = false + results.push({ durationMs: performance.now() - start, queueCalls: queueWire.calls - calls, queueBytes: queueWire.bytes - bytes, renderMs, commits, queueRows: document.querySelectorAll('[data-import-nav="queue"]').length, selectionLabel: document.querySelector('.import-queue-bulk-actions')?.textContent, viewport: [innerWidth,innerHeight] }) + output.textContent = JSON.stringify(results, null, 2); button.disabled = false +} +createRoot(document.getElementById('root')!).render( { if(measuring) { renderMs += duration; commits++ } }}>) diff --git a/apps/desktop/performance/index.html b/apps/desktop/performance/index.html new file mode 100644 index 0000000..60229be --- /dev/null +++ b/apps/desktop/performance/index.html @@ -0,0 +1 @@ +Retune performance experiments
diff --git a/apps/desktop/performance/main.tsx b/apps/desktop/performance/main.tsx new file mode 100644 index 0000000..0feeeeb --- /dev/null +++ b/apps/desktop/performance/main.tsx @@ -0,0 +1,94 @@ +import { Profiler } from 'react' +import { createRoot } from 'react-dom/client' +import App from '../src/App.tsx' +import '../src/index.css' +import { calls, emit, player } from './native.ts' + +const results: unknown[] = [] +let measuring = false +let renders: number[] = [] +let frames: number[] = [] +let longTasks: number[] = [] +let previousFrame = 0 +function frame(now: number) { + if (measuring && previousFrame) frames.push(now - previousFrame) + previousFrame = now + requestAnimationFrame(frame) +} +requestAnimationFrame(frame) +if (PerformanceObserver.supportedEntryTypes.includes('longtask')) { + new PerformanceObserver(list => { if (measuring) longTasks.push(...list.getEntries().map(entry => entry.duration)) }).observe({ type: 'longtask' }) +} +const panel = document.createElement('aside') +panel.style.cssText = 'position:fixed;right:12px;bottom:26px;z-index:10000;background:white;color:black;border:1px solid #888;padding:8px;font:12px monospace;max-width:520px;max-height:180px;overflow:auto' +const button = document.createElement('button') +button.textContent = 'Measure 12 playback ticks' +const output = document.createElement('pre'); output.id = 'performance-results' +panel.append(button, output); document.body.append(panel) +// A controlled document-visibility seam; this does not model native occlusion/CPU. +let simulatedVisibility: DocumentVisibilityState = 'visible' +Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => simulatedVisibility }) +const hiddenLabel = document.createElement('label') +const hiddenInput = document.createElement('input'); hiddenInput.type = 'checkbox' +hiddenLabel.append(hiddenInput, 'Simulate hidden document'); panel.append(hiddenLabel) +hiddenInput.onchange = () => { + simulatedVisibility = hiddenInput.checked ? 'hidden' : 'visible' + document.dispatchEvent(new Event('visibilitychange')) +} +const wait = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds)) +const painted = async () => { await new Promise(requestAnimationFrame); await new Promise(requestAnimationFrame) } +const interactionButton = document.createElement('button') +interactionButton.textContent = 'Measure playlist interactions' +const interactionOutput = document.createElement('pre'); interactionOutput.id = 'performance-interactions' +panel.append(interactionButton, interactionOutput) +const interactionResults: unknown[] = [] +interactionButton.onclick = async () => { + interactionButton.disabled = true; button.disabled = true + document.querySelector('.source-row')!.click() + await wait(500) + const actions: unknown[] = [] + const measure = async (name: string, action: () => void) => { + renders = []; frames = []; longTasks = []; measuring = true + const started = performance.now() + action(); await painted() + measuring = false + actions.push({ name, durationMs: performance.now() - started, renderMs: renders.reduce((sum, value) => sum + value, 0), + commits: renders.length, rows: document.querySelectorAll('.playlist-track-row').length, + focusedRow: document.activeElement?.getAttribute('aria-label'), + longTasksMs: longTasks, maxFrameGapMs: Math.max(0, ...frames) }) + } + await measure('open', () => document.querySelector('.playlist-row')!.click()) + await measure('select', () => document.querySelector('.playlist-track-row')!.click()) + await measure('end', () => document.querySelector('.playlist-track-row')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }))) + await measure('shift-home', () => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', shiftKey: true, bubbles: true }))) + await measure('sort', () => document.querySelector('.playlist-track-header [data-column="name"]')!.click()) + await measure('scroll-middle', () => { const scroll = document.querySelector('.playlist-track-scroll')!; scroll.scrollTop = scroll.scrollHeight / 2 }) + interactionResults.push({ viewport: [innerWidth, innerHeight], actions }) + interactionOutput.textContent = JSON.stringify(interactionResults, null, 2) + interactionButton.disabled = false; button.disabled = false +} +button.onclick = async () => { + button.disabled = true + const first = document.querySelector('[data-track-id], .playlist-track-row') + first?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + emit({ type: 'playerState', payload: player(0) }) + await wait(1500) + renders = []; frames = []; longTasks = [] + const callsBefore = calls.length + measuring = true + const started = performance.now() + for (let elapsed = 1; elapsed <= 12; elapsed++) { emit({ type: 'playerState', payload: player(elapsed) }); await wait(1000) } + measuring = false + const sample = { + scenario: document.querySelector('.playlist-view') ? 'playlist-playback' : 'library-playback', + durationMs: performance.now() - started, commits: renders.length, + totalRenderMs: renders.reduce((sum, duration) => sum + duration, 0), maxRenderMs: Math.max(0, ...renders), + maxFrameGapMs: Math.max(0, ...frames), longTasksMs: longTasks, + ipcDuringTicks: calls.length - callsBefore, rows: document.querySelectorAll('[data-track-id], .playlist-track-row').length, + elapsed: document.querySelector('[aria-label="Playback position"]')?.value, + viewport: [innerWidth, innerHeight], samples: renders, + simulatedVisibility, animationStates: document.querySelector('.marquee')?.getAnimations().map(animation => animation.playState), + } + results.push(sample); output.textContent = JSON.stringify(results, null, 2); button.disabled = false +} +createRoot(document.getElementById('root')!).render( { if (measuring) renders.push(duration) }}>) diff --git a/apps/desktop/performance/native-probe.js b/apps/desktop/performance/native-probe.js new file mode 100644 index 0000000..d1f8c5a --- /dev/null +++ b/apps/desktop/performance/native-probe.js @@ -0,0 +1,74 @@ +// Opt-in bundled harness: actual App, IPC and WebKit; no production entry changes. +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) +const painted = async () => { await new Promise(requestAnimationFrame); await new Promise(requestAnimationFrame) } +const until = async predicate => { + const start = performance.now() + while (!predicate()) { + if (performance.now() - start > 15000) throw new Error('Native fixture timed out') + await wait(10) + } +} +const results = { navigationTimeOrigin: performance.timeOrigin, readyMs: null, interactions: [], visibility: [], playback: [] } +const panel = document.createElement('aside') +panel.style.cssText = 'position:fixed;right:12px;bottom:26px;z-index:10000;background:white;color:black;border:1px solid #888;padding:6px;font:12px monospace' +const output = document.createElement('textarea') +output.readOnly = true +output.setAttribute('aria-label', 'Native performance results') +output.style.cssText = 'display:block;width:380px;height:90px;font:10px monospace' +const show = () => { output.value = JSON.stringify(results) } +const snapshot = () => ({ atMs: performance.now(), state: document.visibilityState, + elapsed: document.querySelector('[aria-label="Playback position"]')?.value, + animationStates: [...document.querySelectorAll('.marquee')].flatMap(node => node.getAnimations().map(animation => animation.playState)) }) +document.addEventListener('visibilitychange', () => { + results.visibility.push(snapshot()); show() + setTimeout(() => { results.visibility.push(snapshot()); show() }, 100) +}) +const button = (label, action) => { + const control = document.createElement('button') + control.textContent = label + control.onclick = async () => { + control.disabled = true + try { await action() } catch (error) { results.error = String(error) } + finally { control.disabled = false; show() } + } + panel.append(control) +} +button('Measure native playlist', async () => { + for (let sample = 0; sample < 3; sample++) { + document.querySelector('.source-row').click() + await until(() => document.querySelector('[data-track-id]')) + await wait(300) + const actions = [] + const measure = async (name, action, ready = () => true) => { + const start = performance.now() + action(); await until(ready); await painted() + actions.push({ name, durationMs: performance.now() - start, + mountedRows: document.querySelectorAll('.playlist-track-row').length, + selectedRows: document.querySelectorAll('.playlist-track-row.selected').length, + focusedRow: document.activeElement?.getAttribute('aria-label') }) + } + await measure('open', () => document.querySelector('.playlist-row').click(), () => document.querySelector('.playlist-track-row')) + await measure('select', () => document.querySelector('.playlist-track-row').click()) + await measure('end', () => document.querySelector('.playlist-track-row').dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }))) + await measure('shift-home', () => document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', shiftKey: true, bubbles: true }))) + await measure('sort', () => document.querySelector('.playlist-track-header [data-column="name"]').click()) + await measure('scroll-middle', () => { const scroll = document.querySelector('.playlist-track-scroll'); scroll.scrollTop = scroll.scrollHeight / 2 }) + results.interactions.push({ viewport: [innerWidth, innerHeight], actions }); show() + } +}) +button('Five-second silent playback', async () => { + try { + document.querySelector('.source-row').click() + await until(() => document.querySelector('[data-track-id]')) + document.querySelector('[data-track-id]').dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + await until(() => document.querySelector('[aria-label="Pause"]')) + results.playback.push(snapshot()) + await wait(5000) + results.playback.push(snapshot()) + } finally { document.querySelector('[aria-label="Pause"]')?.click() } +}) +panel.append(output) +document.body.append(panel) +until(() => document.querySelector('[data-track-id]')).then(painted).then(() => { + results.readyMs = performance.now(); show() +}).catch(error => { results.error = String(error); show() }) diff --git a/apps/desktop/performance/native-process.c b/apps/desktop/performance/native-process.c new file mode 100644 index 0000000..f0f3dc4 --- /dev/null +++ b/apps/desktop/performance/native-process.c @@ -0,0 +1,26 @@ +// Read-only macOS process counters; compile with clang native-process.c -o /tmp/native-process. +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + if (argc != 2) return 2; + char *end; + long value = strtol(argv[1], &end, 10); + if (*end || value <= 0 || value > 2147483647) return 2; + int pid = (int)value; + struct proc_bsdinfo info; + struct rusage_info_v2 usage; + mach_timebase_info_data_t timebase; + if (mach_timebase_info(&timebase) != KERN_SUCCESS) return 1; + if (proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != sizeof(info) + || proc_pid_rusage(pid, RUSAGE_INFO_V2, (rusage_info_t *)&usage)) return 1; + printf("{\"pid\":%d,\"startEpochMs\":%.3f,\"cpuNs\":%llu,\"physicalFootprintBytes\":%llu}\n", + pid, info.pbi_start_tvsec * 1000.0 + info.pbi_start_tvusec / 1000.0, + (unsigned long long)((usage.ri_user_time + usage.ri_system_time) * timebase.numer / timebase.denom), + (unsigned long long)usage.ri_phys_footprint); + return 0; +} diff --git a/apps/desktop/performance/native-sample.py b/apps/desktop/performance/native-sample.py new file mode 100644 index 0000000..7ef6f97 --- /dev/null +++ b/apps/desktop/performance/native-sample.py @@ -0,0 +1,33 @@ +"""Read three 5-second CPU/footprint samples for explicitly attributed macOS PIDs. + +Usage: python3 native-sample.py /tmp/native-process output.json PID [PID ...] +CPU 100% means one core. Audio is controlled separately by the native fixture. +""" +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +probe, output, *pids = sys.argv[1:] +assert pids and all(pid.isdecimal() and int(pid) > 0 for pid in pids) +def read(pid): + return json.loads(subprocess.check_output([probe, str(pid)], text=True)) + +# Small runnable checks for the probe's input boundary and counter units. +assert subprocess.run([probe, '1x'], capture_output=True).returncode == 2 +own = read(os.getpid()) +assert own['physicalFootprintBytes'] > 0 and own['cpuNs'] > 0 +samples = [] +for _ in range(3): + before = [read(pid) for pid in pids] + start = time.monotonic_ns() + time.sleep(5) + after = [read(pid) for pid in pids] + elapsed = time.monotonic_ns() - start + samples.append(dict(durationMs=elapsed / 1e6, before=before, after=after, + cpuPercent=sum(b['cpuNs'] - a['cpuNs'] for a, b in zip(before, after)) / elapsed * 100, + summedPhysicalFootprintBytes=sum(p['physicalFootprintBytes'] for p in after))) +Path(output).write_text(json.dumps(samples, indent=2) + '\n') +print(json.dumps([dict(cpuPercent=s['cpuPercent'], footprintMiB=s['summedPhysicalFootprintBytes'] / 1048576) for s in samples])) diff --git a/apps/desktop/performance/native.config.json b/apps/desktop/performance/native.config.json new file mode 100644 index 0000000..f2c2ccf --- /dev/null +++ b/apps/desktop/performance/native.config.json @@ -0,0 +1,8 @@ +{ + "productName": "Retune Performance", + "identifier": "com.rianjs.retune.performance", + "build": { "beforeBuildCommand": "npm exec vite -- build --config performance/native.vite.config.mjs" }, + "app": { + "windows": [{ "label": "main", "title": "Retune Performance", "width": 1120, "height": 720, "resizable": true }] + } +} diff --git a/apps/desktop/performance/native.ts b/apps/desktop/performance/native.ts new file mode 100644 index 0000000..025f225 --- /dev/null +++ b/apps/desktop/performance/native.ts @@ -0,0 +1,66 @@ +import { defaultSettings, initialState } from '../src/appState.ts' +import type { MainEvent } from '../src/ipc.ts' +import type { PlayerState, Track } from '../src/types.ts' + +export const tracks: Track[] = Array.from({ length: 4000 }, (_, index) => ({ + id: index + 1, uri: `file:///fixture/${index}`, name: index === 0 ? 'Track 0 — a deliberately long track title to exercise native marquee presentation while playback continues' : `Track ${index}`, art: `Artist ${index % 200}`, + alb: `Album ${index % 400}`, cat: 'Soundtrack', durationSecs: 3600, enabled: true, rating: null, + isLocal: true, discNo: 1, trackNo: index % 12 + 1, playCount: 0, kind: 'MP3', bitrateKbps: 320, + lastPlayedAt: null, addedAt: null, releaseDate: null, +})) +// Duplicate URIs deliberately represent separate playlist entries. +export const playlistTracks = [...tracks, ...tracks.slice(0, 100)] +export const queueWire = { calls: 0, bytes: 0, inFlight: 0 } +const importerFixture = new URLSearchParams(location.search).has('queueFixture') +const importQueue = Array.from({ length: 8000 }, (_, index) => ({ page: index + 1, artist: `Artist ${index % 200}`, album: `Batch ${index + 1}`, customBatch: false, collectionShaped: false, albumLabelCount: 1, playCount: 20, importedPlayCount: 0, remainingPlayCount: 20, latest: 1700000000 + index, sourceCount: 1, remaining: true, albumEntities: 0, trackEntities: 1, status: null, error: null, errorCode: null, retryAt: null })) +export const calls: { command: string; args: unknown }[] = [] +export class Channel { constructor(public onmessage: (event: MainEvent) => void) {} } +let channel: Channel | undefined +const listeners = new Map void>>() +export const listen = async (name: string, handler: (event: { payload: unknown }) => void) => { + const handlers = listeners.get(name) ?? new Set(); handlers.add(handler); listeners.set(name, handlers) + return () => { handlers.delete(handler) } +} +export const getCurrentWindow = () => ({ label: new URLSearchParams(location.search).get('window') ?? 'main', setTitle: async () => {}, onDragDropEvent: async () => () => {} }) +export function emit(event: MainEvent) { channel?.onmessage(event) } +export function invalidate(name: string, payload: unknown = null) { listeners.get(name)?.forEach(handler => handler({ payload })) } +export const player = (elapsed: number, overrides: Partial = {}): PlayerState => ({ + trackId: 1, uri: tracks[0].uri, elapsed, isPlaying: true, external: false, + name: tracks[0].name, art: tracks[0].art, alb: tracks[0].alb, durationSecs: 3600, shuffle: false, ...overrides, +}) +export async function invoke(command: string, args: Record = {}) { + calls.push({ command, args }) + if (command === 'subscribe_main_events') { channel = args.channel; return 1 } + if (command === 'unsubscribe_main_events') { channel = undefined; return } + if (command === 'get_settings') return { ...defaultSettings, theme: new URLSearchParams(location.search).get('theme') ?? defaultSettings.theme } + if (command === 'get_appearance') return { theme: new URLSearchParams(location.search).get('theme') ?? 'light' } + if (command === 'genre_values') return [] + if (command === 'connection_state') return initialState.connection + if (command === 'spotify_sync_status') return initialState.spotifySyncStatus + if (command === 'lastfm_state') return initialState.lastfm + if (command === 'lastfm_import_state') return importerFixture ? { ...initialState.lastfmImport, phase: 'review', remaining: importQueue.length, syncProblem: null } : initialState.lastfmImport + if (command === 'lastfm_import_queue') { + if (!importerFixture) return { cursor: 0, items: [], total: 0, nextCursor: null } + queueWire.inFlight++ + await new Promise(resolve => setTimeout(resolve, 15)) + const cursor = Number(args.cursor ?? 0), end = Math.min(cursor + Number(args.limit ?? 1000), importQueue.length) + const wire = JSON.stringify({ cursor, items: importQueue.slice(cursor, end), total: importQueue.length, nextCursor: end < importQueue.length ? end : null }) + queueWire.calls++; queueWire.bytes += new TextEncoder().encode(wire).length; queueWire.inFlight-- + return JSON.parse(wire) + } + if (command === 'lastfm_import_playback') return { uri: null, isPlaying: false } + if (command === 'track_artwork') return null + if (command === 'browse') { + const query = String(args.query ?? '').toLowerCase() + const selected = tracks.filter(track => [track.name, track.art, track.alb, track.cat].some(value => value.toLowerCase().includes(query))) + return { facets: { cats: ['Soundtrack'], arts: [...new Set(tracks.map(track => track.art))], albs: [...new Set(tracks.map(track => track.alb))] }, tracks: selected, + albumRating: null, albumRatingArtist: null, albumRatingAmbiguous: false, + counts: { tracks: selected.length, totalSecs: selected.length * 3600, perSource: { music: tracks.length, podcasts: 0, audiobooks: 0 } } } + } + if (command === 'playlists_list') return [{ id: 'fixture', name: 'Performance playlist', owned: true, itemsAvailable: true, trackCount: playlistTracks.length }] + if (command === 'playlist_tracks') return playlistTracks + if (command === 'play_tracks') { queueMicrotask(() => emit({ type: 'playerState', payload: player(0) })); return 'started' } + if (command === 'player_toggle') { emit({ type: 'playerState', payload: player(0, { isPlaying: false }) }); return } + if (command === 'player_seek') { emit({ type: 'playerState', payload: player(args.seconds) }); return } + return null +} diff --git a/apps/desktop/performance/native.vite.config.mjs b/apps/desktop/performance/native.vite.config.mjs new file mode 100644 index 0000000..f7be1ad --- /dev/null +++ b/apps/desktop/performance/native.vite.config.mjs @@ -0,0 +1,12 @@ +import { mergeConfig } from 'vite' +import config from '../vite.config.ts' + +export default mergeConfig(config, { + plugins: [{ + name: 'native-performance-probe', + transformIndexHtml: { + order: 'pre', + handler: () => [{ tag: 'script', attrs: { type: 'module', src: '/performance/native-probe.js' }, injectTo: 'head' }], + }, + }], +}) diff --git a/apps/desktop/performance/native_fixture.py b/apps/desktop/performance/native_fixture.py new file mode 100644 index 0000000..be8e89d --- /dev/null +++ b/apps/desktop/performance/native_fixture.py @@ -0,0 +1,43 @@ +"""Seed only the isolated native performance app; never read user library/tokens.""" +import argparse +import json +import os +from pathlib import Path +import wave + +parser = argparse.ArgumentParser() +parser.add_argument('--data-dir', type=Path, required=True) +parser.add_argument('--media-dir', type=Path, required=True) +parser.add_argument('--count', type=int, default=4000) +args = parser.parse_args() +assert args.data_dir.name == 'com.rianjs.retune.performance' +assert args.count > 0 +args.data_dir.mkdir(parents=True, exist_ok=True) +assert not (args.data_dir / 'library.json').exists(), 'Refuse to replace an existing library' +args.media_dir.mkdir(parents=True, exist_ok=True) +audio = args.media_dir / 'silence.wav' +with wave.open(str(audio), 'wb') as output: + output.setparams((1, 2, 8000, 0, 'NONE', 'not compressed')) + for _ in range(3600): + output.writeframes(b'\0' * 16000) +tracks = [] +for index in range(args.count): + media = args.media_dir / f'{index:05}.wav' + if not media.exists(): + os.link(audio, media) + tracks.append(dict(id=index + 1, uri=media.resolve().as_uri(), source='music', + cat='Soundtrack', art=f'Artist {index % 200:03}', alb=f'Album {index % 400:03}', + name=f'Track {index:05} — long playback title for native marquee measurement', + duration=dict(secs=3600, nanos=0), track_no=index % 12 + 1, disc_no=1, + play_count=0, last_played_at=None, added_at=None, release_date=None, + kind='WAV', bitrate_kbps=128, rating=None, orig_cat=None)) +settings = dict(theme='light', zoom=1, zebra=True, columnOrder=['track', 'name', 'artist', 'album', 'time', 'plays', 'rating', 'genre'], + autoAddSpotifyLibrary=False, autoConnect=False, spotifyClientId='', volume=0, lastfmScrobbling=False) +uris = [track['uri'] for track in tracks] + [track['uri'] for track in tracks[:100]] +playlists = dict(playlists=[dict(id='fixture', name='Performance playlist', snapshot_id='fixture', owned=True, + owner=None, track_count=len(uris), tracks=uris, track_metadata_version=1, spotify_tracks=[])]) +for name, value in [('library.json', dict(version=1, library=dict(tracks=tracks, album_ratings=[], next_id=args.count + 1))), ('settings.json', settings), ('playlists.json', playlists)]: + temporary = args.data_dir / f'{name}.tmp' + temporary.write_text(json.dumps(value)) + os.replace(temporary, args.data_dir / name) +print(json.dumps(dict(tracks=args.count, audioSeconds=3600, dataDir=str(args.data_dir), mediaDir=str(args.media_dir)))) diff --git a/apps/desktop/performance/startup.html b/apps/desktop/performance/startup.html new file mode 100644 index 0000000..0977e12 --- /dev/null +++ b/apps/desktop/performance/startup.html @@ -0,0 +1,21 @@ + +Retune startup benchmark +
diff --git a/apps/desktop/performance/startup.vite.config.mjs b/apps/desktop/performance/startup.vite.config.mjs new file mode 100644 index 0000000..a621d7e --- /dev/null +++ b/apps/desktop/performance/startup.vite.config.mjs @@ -0,0 +1,6 @@ +import { defineConfig, mergeConfig } from 'vite' +import config from './vite.config.mjs' +export default mergeConfig(config, defineConfig({ + build: { outDir: 'performance-dist', manifest: true, rollupOptions: { input: 'performance/startup.html' } }, + preview: { host: '127.0.0.1', port: 5186, strictPort: true }, +})) diff --git a/apps/desktop/performance/vite.config.mjs b/apps/desktop/performance/vite.config.mjs new file mode 100644 index 0000000..ecd0ae9 --- /dev/null +++ b/apps/desktop/performance/vite.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { fileURLToPath } from 'node:url' +const native = fileURLToPath(new URL('./native.ts', import.meta.url)) +export default defineConfig({ + plugins: [react()], + resolve: { alias: Object.fromEntries(['core', 'event', 'window'].map(name => [`@tauri-apps/api/${name}`, native])) }, + server: { host: '127.0.0.1', port: 5185, strictPort: true }, +}) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 05349a8..6ad1dc8 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -761,7 +761,7 @@ fn import_local_folder(app: &tauri::AppHandle) { } fn handle_local_drag_event(app: &tauri::AppHandle, label: &str, event: &tauri::WindowEvent) { - if label != "main" { + if label != "main" || app.try_state::().is_none() { return; } let tauri::WindowEvent::DragDrop(event) = event else { @@ -785,7 +785,7 @@ fn handle_local_drag_event(app: &tauri::AppHandle, label: &str, event: &tauri::W } } -fn install_file_menu(app: &tauri::App, settings: &Settings) -> tauri::Result { +fn install_file_menu(app: &tauri::AppHandle, settings: &Settings) -> tauri::Result { let preferences = MenuItemBuilder::with_id("preferences", "Preferences…") .accelerator("CmdOrCtrl+,") .build(app)?; @@ -894,62 +894,67 @@ fn install_file_menu(app: &tauri::App, settings: &Settings) -> tauri::Result { - let _ = emit_main(app, "get-info", ()); + app.on_menu_event(|app, event| { + if app.try_state::().is_none() { + return; } - "setup_library" => { - let _ = emit_main(app, "open-setup", ()); - } - "add_local_files" => import_local_files(app), - "add_local_folder" => import_local_folder(app), - "export_library" => backup::export_library(app), - "restore_library" => backup::import_library(app, true), - "merge_library" => backup::import_library(app, false), - "sync_spotify" => { - let handle = app.clone(); - tauri::async_runtime::spawn(async move { - if let Err(error) = sync_spotify(&handle).await { - notify_error(&handle, error); - } - }); - } - "connect_spotify" => { - let handle = app.clone(); - tauri::async_runtime::spawn(async move { - if let Err(error) = spotify_commands::connect_spotify(handle.clone()).await { - notify_error(&handle, error); - } - }); - } - "disconnect_spotify" => { - let handle = app.clone(); - tauri::async_runtime::spawn(async move { - if let Err(error) = spotify_commands::disconnect_spotify(handle.clone()).await { - notify_error(&handle, error); - } - }); - } - "about_retune" => { - app.dialog() - .message(format!( - "Retune {}\n\nOverlay edits stay local", - app.package_info().version - )) - .title("About Retune") - .show(|_| {}); - } - "preferences" => { - let _ = emit_main(app, "open-preferences", ()); - } - "zoom_in" | "zoom_out" | "actual_size" | "toggle_zebra" | "toggle_browser" - | "theme_system" | "theme_light" | "theme_dark" => { - let _ = emit_main(app, "view-action", event.id().as_ref()); - } - "play_pause" | "previous" | "next" => { - let _ = emit_main(app, "player-action", event.id().as_ref()); + match event.id().as_ref() { + "get_info" => { + let _ = emit_main(app, "get-info", ()); + } + "setup_library" => { + let _ = emit_main(app, "open-setup", ()); + } + "add_local_files" => import_local_files(app), + "add_local_folder" => import_local_folder(app), + "export_library" => backup::export_library(app), + "restore_library" => backup::import_library(app, true), + "merge_library" => backup::import_library(app, false), + "sync_spotify" => { + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = sync_spotify(&handle).await { + notify_error(&handle, error); + } + }); + } + "connect_spotify" => { + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = spotify_commands::connect_spotify(handle.clone()).await { + notify_error(&handle, error); + } + }); + } + "disconnect_spotify" => { + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = spotify_commands::disconnect_spotify(handle.clone()).await { + notify_error(&handle, error); + } + }); + } + "about_retune" => { + app.dialog() + .message(format!( + "Retune {}\n\nOverlay edits stay local", + app.package_info().version + )) + .title("About Retune") + .show(|_| {}); + } + "preferences" => { + let _ = emit_main(app, "open-preferences", ()); + } + "zoom_in" | "zoom_out" | "actual_size" | "toggle_zebra" | "toggle_browser" + | "theme_system" | "theme_light" | "theme_dark" => { + let _ = emit_main(app, "view-action", event.id().as_ref()); + } + "play_pause" | "previous" | "next" => { + let _ = emit_main(app, "player-action", event.id().as_ref()); + } + _ => {} } - _ => {} }); Ok(MenuChecks { zebra, @@ -980,6 +985,418 @@ pub(crate) fn emit_main_event( state.main_events.send(event) } +struct StartupFiles { + library: Library, + recovery_notice: Option, + spotify_library: store::SpotifyLibraryState, + playlists: playlists::PlaylistCache, + settings: Settings, + startup_cooldown: Option, +} + +async fn start_startup_load(app_data_dir: PathBuf) -> Result { + tauri::async_runtime::spawn_blocking(move || { + load_startup_files(app_data_dir).map_err(|error| error.to_string()) + }) + .await + .map_err(|error| error.to_string())? +} + +async fn await_startup( + mut ready: tokio::sync::watch::Receiver>>, +) -> Result<(), String> { + ready + .wait_for(Option::is_some) + .await + .map_err(|_| "Startup task ended before initialization completed.".to_string())? + .clone() + .unwrap() +} + +fn load_startup_files( + app_data_dir: PathBuf, +) -> Result> { + spotify_sync_commit::Store::new(&app_data_dir) + .recover() + .map_err(std::io::Error::other)?; + restore::RestoreStore::new(&app_data_dir) + .recover() + .map_err(std::io::Error::other)?; + let store = FsOverlayStore::new(&app_data_dir); + let (library, recovery_notice, needs_save) = match store.load() { + Ok(Some(library)) => (library, None, false), + Ok(None) => { + let library = initial_library(cfg!(debug_assertions)); + (library, None, true) + } + Err(StoreError::Import(error)) => { + let corrupt = store.quarantine_corrupt()?; + let library = Library::new(); + ( + library, + Some(format!( + "Retune could not load your library ({error}). The corrupt file was moved to {} and an empty library was started.", + corrupt.display() + )), + true, + ) + } + Err(error) => return Err(error.into()), + }; + if needs_save { + store.save(&library)?; + } + let spotify_library = FsSpotifyLibraryStore::new(&app_data_dir).load()?; + let playlists = FsPlaylistStore::new(&app_data_dir).load()?; + let settings = FsSettingsStore::new(&app_data_dir).load_for_startup()?; + let startup_cooldown = FsCooldownStore::new(&app_data_dir) + .effective_cooldown(unix_now()) + .map_err(std::io::Error::other)?; + Ok(StartupFiles { + library, + recovery_notice, + spotify_library, + playlists, + settings, + startup_cooldown, + }) +} + +fn finish_startup( + app: &tauri::AppHandle, + app_data_dir: PathBuf, + files: StartupFiles, +) -> Result<(), Box> { + let StartupFiles { + library, + recovery_notice, + spotify_library, + playlists, + settings, + startup_cooldown, + } = files; + let store = FsOverlayStore::new(&app_data_dir); + let settings_store = FsSettingsStore::new(&app_data_dir); + let cooldown_store = FsCooldownStore::new(&app_data_dir); + let artist_genres_store = FsArtistGenresStore::new(&app_data_dir); + let spotify_library_store = FsSpotifyLibraryStore::new(&app_data_dir); + let spotify_catalog_store = FsSpotifyCatalogStore::new(&app_data_dir); + let spotify_catalog = Arc::new(Mutex::new(SpotifyCatalog::default())); + let spotify_catalog_saved_generation = Arc::new(AtomicU64::new(0)); + let spotify_catalog_flush_gate = Arc::new(Mutex::new(())); + let catalog_hydration = spawn_catalog_hydration( + Arc::clone(&spotify_catalog), + Arc::clone(&spotify_catalog_saved_generation), + Arc::clone(&spotify_catalog_flush_gate), + { + let store = spotify_catalog_store.clone(); + move || store.load() + }, + ); + let playlist_store = FsPlaylistStore::new(&app_data_dir); + let menu_checks = install_file_menu(app, &settings)?; + // Dev builds keep tokens in a 0600 plaintext file. Release keeps + // only the encryption key in the native credential store. + let use_dev_token_store = cfg!(any(debug_assertions, feature = "dev-token-store")); + let backing: Box = if use_dev_token_store { + Box::new(store::FsTokenStore::new(&app_data_dir)) + } else { + Box::new(EncryptedFsTokenStore::new(&app_data_dir).map_err(std::io::Error::other)?) + }; + let token_store = Arc::new(CachedTokenStore::new(backing)); + let lastfm_app = app.clone(); + let lastfm = lastfm::Service::new_unhydrated( + &app_data_dir, + use_dev_token_store, + settings.lastfm_scrobbling, + lastfm::credentials_from( + option_env!("RETUNE_LASTFM_API_KEY"), + option_env!("RETUNE_LASTFM_SHARED_SECRET"), + ), + Arc::new(move |_| { + let _ = emit_main(&lastfm_app, "lastfm-changed", ()); + }), + ); + let restore_mutations = Arc::new(restore_latch::RestoreMutationState::default()); + let lastfm_import = lastfm_import::Service::new_unhydrated_with_restore_state( + &app_data_dir, + Arc::clone(&restore_mutations), + ); + let connection = ConnectionState::from_tokens(None); + menu_checks.sync_connection(&connection)?; + let spotify = spotify_provider( + &settings.spotify_client_id, + Arc::clone(&token_store), + Arc::clone(&spotify_catalog), + ) + .map_err(std::io::Error::other)?; + let startup_client_id = settings.spotify_client_id.clone(); + let startup_auto_connect = settings.auto_connect; + let startup_last_full_sync = settings.last_full_sync; + let startup_next_spotify_sync = settings.next_spotify_sync; + let startup_backend = settings.playback_backend; + let initial_volume = settings.volume; + let playback = Arc::new(Playback::new( + settings.repeat, + settings.shuffle, + settings.play_threshold_percent, + AudioSettings { + bitrate: settings.streaming_bitrate, + normalize: settings.normalize_volume, + gapless: settings.gapless, + }, + Some(app_data_dir.clone()), + )); + playback.set_requested_backend(settings.playback_backend); + let media_control_app = app.clone(); + let media_keys = media_keys::MediaKeys::spawn(app, move |control| { + let app = media_control_app.clone(); + tauri::async_runtime::spawn(async move { + if app.try_state::().is_none() { + return; + } + if let Err(error) = playback_commands::handle_media_control(&app, control).await { + notify_error(&app, error); + } + }); + }); + let lastfm_enabled = settings.lastfm_scrobbling; + let lastfm_import_startup = Arc::clone(&lastfm_import); + let playback_effects = PlaybackEffects::start(app.clone(), Arc::clone(&lastfm)); + app.manage(AppState { + library: LibraryState::new_with_restore_state( + library, + store, + Arc::clone(&restore_mutations), + ), + spotify_membership: spotify_membership::SpotifyMembership::new_with_restore_state( + spotify_library, + spotify_library_store, + Arc::clone(&restore_mutations), + ), + settings: SettingsState::new_with_restore_state( + settings, + settings_store, + Arc::clone(&restore_mutations), + ), + cooldown_store, + artist_genres_store, + playlists: PlaylistState::new_with_restore_state( + playlists, + playlist_store, + Arc::clone(&restore_mutations), + ), + menu_checks: Some(menu_checks), + main_events: main_events::MainEventSink::new(recovery_notice), + token_store, + spotify_catalog, + spotify_catalog_store, + spotify_catalog_saved_generation, + spotify_catalog_flush_gate, + spotify: Mutex::new(spotify), + artwork_cache: Mutex::default(), + playback: Arc::clone(&playback), + playback_effects, + lastfm: Arc::clone(&lastfm), + lastfm_import, + media_keys, + spotify_session: spotify_commands::SpotifySession::default(), + sync_orchestrator: SyncOrchestrator::default(), + catalog_flush_task: Mutex::new(None), + playlist_reauth_notified: AtomicBool::new(false), + local_import_active: Arc::new(AtomicBool::new(false)), + restore_mutations, + shutdown_state: AtomicU8::new(0), + }); + let catalog_app = app.clone(); + let catalog_task = tauri::async_runtime::spawn(async move { + match catalog_hydration.await { + Ok(Ok(true)) => { + let _ = emit_main(&catalog_app, "library-changed", ()); + } + Ok(Ok(false)) => { + log::debug!("Skipped stale Spotify catalog hydration"); + } + Ok(Err(error)) => notify_error( + &catalog_app, + format!("Could not load the Spotify catalog: {error}"), + ), + Err(error) => notify_error( + &catalog_app, + format!("Spotify catalog hydration failed: {error}"), + ), + } + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.tick().await; + loop { + interval.tick().await; + let flush_app = catalog_app.clone(); + match tauri::async_runtime::spawn_blocking(move || { + let state = flush_app.state::(); + flush_spotify_catalog(&state) + }) + .await + { + Ok(Ok(())) => {} + Ok(Err(error)) => { + log::warn!("Could not persist Spotify catalog: {error}"); + } + Err(error) => { + log::warn!("Spotify catalog persistence task failed: {error}"); + } + } + } + }); + *app.state::() + .catalog_flush_task + .lock() + .expect("catalog flush-task mutex poisoned") = Some(catalog_task); + let backfill_app = app.clone(); + tauri::async_runtime::spawn(async move { + let event_app = backfill_app.clone(); + match tauri::async_runtime::spawn_blocking(move || { + backfill_app + .state::() + .library + .mutate(|library| Ok(localfiles::backfill_metadata(library))) + }) + .await + { + Ok(Ok(true)) => { + let _ = emit_main(&event_app, "library-changed", ()); + } + Ok(Ok(false)) => {} + Ok(Err(error)) => notify_error(&event_app, error), + Err(error) => notify_error(&event_app, error.to_string()), + } + }); + let lastfm_startup = Arc::clone(&lastfm); + let profile_app = app.clone(); + tauri::async_runtime::spawn(async move { + let import_hydration = async { + let result = lastfm_import_startup.hydrate().await; + if result.is_ok() { + let _ = profile_app.emit_to("main", "lastfm-import-changed", ()); + let _ = profile_app.emit_to("lastfm-importer", "lastfm-import-changed", ()); + } + result + }; + let (lastfm_result, import_result) = + tokio::join!(lastfm_startup.hydrate(), import_hydration); + if let Err(error) = &lastfm_result { + notify_error(&profile_app, error.clone()); + } + if let Err(error) = &import_result { + notify_error(&profile_app, error.clone()); + } + lastfm_startup.set_enabled(lastfm_enabled).await; + let _ = settings_commands::set_lastfm_scrobbling(&profile_app, lastfm_enabled).await; + if import_result.is_ok() { + lastfm_import::resume_persisted_import(profile_app.clone()).await; + lastfm_import::resume_persisted_apply(profile_app.clone()).await; + let _ = lastfm_import_startup.backfill_completed_mappings().await; + if lastfm_result.is_ok() && lastfm_startup.state().await.connected { + let _ = lastfm_import::commands::sync_lastfm_plays(profile_app.clone()).await; + } + } + }); + let provider_app = app.clone(); + let playback_effect_app = app.clone(); + playback.listen( + move || provider_from(&provider_app.state::()), + move |effect| playback_commands::execute_effect(&playback_effect_app, effect), + ); + { + let handle = app.clone(); + let credential_dir = app_data_dir.clone(); + tauri::async_runtime::spawn(async move { + let load_handle = handle.clone(); + let loaded = load_startup_credentials(move || { + match load_handle.state::().token_store.load() { + Ok(tokens) => Ok((ConnectionState::from_tokens(tokens), None)), + Err(retune_spotify::Error::TokenStoreCorrupt(error)) => { + let corrupt = quarantine_token_file( + &credential_dir, + use_dev_token_store, + unix_now(), + ) + .map_err(|error| error.to_string())?; + Ok(( + ConnectionState::from_tokens(None), + Some(format!( + "Retune could not load your Spotify credentials ({error}). The damaged file was moved to {}. Reconnect Spotify to continue.", + corrupt.display() + )), + )) + } + Err(error) => { + log::warn!("Token store unavailable at startup: {error}"); + Ok((ConnectionState::from_tokens(None), None)) + } + } + }) + .await; + let (connection, notice) = match loaded { + Ok(Ok(loaded)) => loaded, + Ok(Err(error)) => { + notify_error(&handle, error); + (ConnectionState::from_tokens(None), None) + } + Err(error) => { + notify_error(&handle, error.to_string()); + (ConnectionState::from_tokens(None), None) + } + }; + if let Some(notice) = notice { + log::warn!("{notice}"); + let _ = emit_main_event(&handle, main_events::MainEvent::StartupNotice(notice)); + } + if let Err(error) = emit_connection_state_async(&handle).await { + notify_error(&handle, error); + } + let startup_action = startup_action( + &connection, + &startup_client_id, + startup_auto_connect, + startup_last_full_sync, + startup_next_spotify_sync, + startup_cooldown, + unix_now(), + ); + if startup_action == StartupAction::Nothing && connection.connected { + if let Some(deadline) = effective_sync_deadline( + scheduled_sync_deadline(startup_next_spotify_sync, startup_last_full_sync), + startup_cooldown, + unix_now(), + ) { + spotify_commands::schedule_auto_resume(&handle, deadline); + } + } + let activate_local = connection.connected + && connection.playback_authorized + && startup_backend == PlaybackBackend::Local; + if activate_local { + let state = handle.state::(); + if let Err(error) = switch_to_local(&state, initial_volume).await { + notify_error(&handle, error); + } + } + let result = match startup_action { + StartupAction::Sync => sync_spotify(&handle).await, + StartupAction::Connect => spotify_commands::connect_spotify(handle.clone()).await, + StartupAction::Nothing => match provider_from(&handle.state::()) { + Ok(client) => playlist_commands::sync_playlists(&handle, client.as_ref()).await, + Err(error) => Err(error), + }, + }; + if let Err(error) = result { + notify_error(&handle, error); + } + }); + } + Ok(()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = tauri::Builder::default(); @@ -1005,7 +1422,10 @@ pub fn run() { ) .build(), ); - let app = builder.invoke_handler(tauri::generate_handler![ + let (startup_completion, startup_ready) = tokio::sync::watch::channel(None); + let invoke_ready = startup_ready.clone(); + let commands: Arc) -> bool + Send + Sync> = + Arc::new(tauri::generate_handler![ library_commands::browse, library_commands::metadata_values, library_commands::genre_values, @@ -1096,8 +1516,39 @@ pub fn run() { diagnostics::load_diagnostics, diagnostics::email_diagnostics, external_links::open_external_destination - ]) - .setup(|app| { + ]); + let app = builder + .invoke_handler(move |invoke| { + let readiness = invoke_ready.borrow().clone(); + if let Some(result) = readiness { + return match result { + Ok(()) => commands(invoke), + Err(error) => { + invoke + .resolver + .reject(format!("Retune could not start: {error}")); + true + } + }; + } + let ready = invoke_ready.clone(); + let commands = Arc::clone(&commands); + tauri::async_runtime::spawn(async move { + match await_startup(ready).await { + Ok(()) => { + let resolver = invoke.resolver.clone(); + if !commands(invoke) { + resolver.reject("Unknown Retune command"); + } + } + Err(error) => invoke + .resolver + .reject(format!("Retune could not start: {error}")), + } + }); + true + }) + .setup(move |app| { app.handle().plugin( tauri_plugin_log::Builder::default() .level(log::LevelFilter::Info) @@ -1111,374 +1562,26 @@ pub fn run() { diagnostics::SESSION_START_MARKER ); let app_data_dir = app.path().app_data_dir()?; - spotify_sync_commit::Store::new(&app_data_dir) - .recover() - .map_err(std::io::Error::other)?; - restore::RestoreStore::new(&app_data_dir) - .recover() - .map_err(std::io::Error::other)?; - let store = FsOverlayStore::new(&app_data_dir); - let (library, recovery_notice, needs_save) = match store.load() { - Ok(Some(library)) => (library, None, false), - Ok(None) => { - let library = initial_library(cfg!(debug_assertions)); - (library, None, true) - } - Err(StoreError::Import(error)) => { - let corrupt = store.quarantine_corrupt()?; - let library = Library::new(); - ( - library, - Some(format!( - "Retune could not load your library ({error}). The corrupt file was moved to {} and an empty library was started.", - corrupt.display() - )), - true, - ) - } - Err(error) => return Err(error.into()), - }; - if needs_save { - store.save(&library)?; - } - let settings_store = FsSettingsStore::new(&app_data_dir); - let cooldown_store = FsCooldownStore::new(&app_data_dir); - let artist_genres_store = FsArtistGenresStore::new(&app_data_dir); - let spotify_library_store = FsSpotifyLibraryStore::new(&app_data_dir); - let spotify_library = spotify_library_store.load()?; - let spotify_catalog_store = FsSpotifyCatalogStore::new(&app_data_dir); - let spotify_catalog = Arc::new(Mutex::new(SpotifyCatalog::default())); - let spotify_catalog_saved_generation = Arc::new(AtomicU64::new(0)); - let spotify_catalog_flush_gate = Arc::new(Mutex::new(())); - let catalog_hydration = spawn_catalog_hydration( - Arc::clone(&spotify_catalog), - Arc::clone(&spotify_catalog_saved_generation), - Arc::clone(&spotify_catalog_flush_gate), - { - let store = spotify_catalog_store.clone(); - move || store.load() - }, - ); - let playlist_store = FsPlaylistStore::new(&app_data_dir); - let playlists = playlist_store.load()?; - let settings = settings_store.load()?.unwrap_or_default(); - settings_store.save(&settings)?; - let startup_cooldown = cooldown_store - .effective_cooldown(unix_now()) - .map_err(std::io::Error::other)?; - let menu_checks = install_file_menu(app, &settings)?; - // Dev builds keep tokens in a 0600 plaintext file. Release keeps - // only the encryption key in the native credential store. - let use_dev_token_store = cfg!(any(debug_assertions, feature = "dev-token-store")); - let backing: Box = if use_dev_token_store { - Box::new(store::FsTokenStore::new(&app_data_dir)) - } else { - Box::new(EncryptedFsTokenStore::new(&app_data_dir).map_err(std::io::Error::other)?) - }; - let token_store = Arc::new(CachedTokenStore::new(backing)); - let lastfm_app = app.handle().clone(); - let lastfm = lastfm::Service::new_unhydrated( - &app_data_dir, - use_dev_token_store, - settings.lastfm_scrobbling, - lastfm::credentials_from( - option_env!("RETUNE_LASTFM_API_KEY"), - option_env!("RETUNE_LASTFM_SHARED_SECRET"), - ), - Arc::new(move |_| { - let _ = emit_main(&lastfm_app, "lastfm-changed", ()); - }), - ); - let restore_mutations = Arc::new(restore_latch::RestoreMutationState::default()); - let lastfm_import = lastfm_import::Service::new_unhydrated_with_restore_state( - &app_data_dir, - Arc::clone(&restore_mutations), - ); - let connection = ConnectionState::from_tokens(None); - menu_checks.sync_connection(&connection)?; - let spotify = spotify_provider( - &settings.spotify_client_id, - Arc::clone(&token_store), - Arc::clone(&spotify_catalog), - ) - .map_err(std::io::Error::other)?; - let startup_client_id = settings.spotify_client_id.clone(); - let startup_auto_connect = settings.auto_connect; - let startup_last_full_sync = settings.last_full_sync; - let startup_next_spotify_sync = settings.next_spotify_sync; - let startup_backend = settings.playback_backend; - let initial_volume = settings.volume; - let playback = Arc::new(Playback::new( - settings.repeat, - settings.shuffle, - settings.play_threshold_percent, - AudioSettings { - bitrate: settings.streaming_bitrate, - normalize: settings.normalize_volume, - gapless: settings.gapless, - }, - Some(app_data_dir.clone()), - )); - playback.set_requested_backend(settings.playback_backend); - let media_control_app = app.handle().clone(); - let media_keys = media_keys::MediaKeys::spawn(app.handle(), move |control| { - let app = media_control_app.clone(); - tauri::async_runtime::spawn(async move { - if let Err(error) = playback_commands::handle_media_control(&app, control).await - { - notify_error(&app, error); - } - }); - }); - let lastfm_enabled = settings.lastfm_scrobbling; - let lastfm_import_startup = Arc::clone(&lastfm_import); - let playback_effects = - PlaybackEffects::start(app.handle().clone(), Arc::clone(&lastfm)); - app.manage(AppState { - library: LibraryState::new_with_restore_state( - library, - store, - Arc::clone(&restore_mutations), - ), - spotify_membership: spotify_membership::SpotifyMembership::new_with_restore_state( - spotify_library, - spotify_library_store, - Arc::clone(&restore_mutations), - ), - settings: SettingsState::new_with_restore_state( - settings, - settings_store, - Arc::clone(&restore_mutations), - ), - cooldown_store, - artist_genres_store, - playlists: PlaylistState::new_with_restore_state( - playlists, - playlist_store, - Arc::clone(&restore_mutations), - ), - menu_checks: Some(menu_checks), - main_events: main_events::MainEventSink::new(recovery_notice), - token_store, - spotify_catalog, - spotify_catalog_store, - spotify_catalog_saved_generation, - spotify_catalog_flush_gate, - spotify: Mutex::new(spotify), - artwork_cache: Mutex::default(), - playback: Arc::clone(&playback), - playback_effects, - lastfm: Arc::clone(&lastfm), - lastfm_import, - media_keys, - spotify_session: spotify_commands::SpotifySession::default(), - sync_orchestrator: SyncOrchestrator::default(), - catalog_flush_task: Mutex::new(None), - playlist_reauth_notified: AtomicBool::new(false), - local_import_active: Arc::new(AtomicBool::new(false)), - restore_mutations, - shutdown_state: AtomicU8::new(0), - }); - let catalog_app = app.handle().clone(); - let catalog_task = tauri::async_runtime::spawn(async move { - match catalog_hydration.await { - Ok(Ok(true)) => { - let _ = emit_main(&catalog_app, "library-changed", ()); - } - Ok(Ok(false)) => { - log::debug!("Skipped stale Spotify catalog hydration"); - } - Ok(Err(error)) => notify_error( - &catalog_app, - format!("Could not load the Spotify catalog: {error}"), - ), - Err(error) => notify_error( - &catalog_app, - format!("Spotify catalog hydration failed: {error}"), - ), - } - let mut interval = tokio::time::interval(Duration::from_secs(30)); - interval.tick().await; - loop { - interval.tick().await; - let flush_app = catalog_app.clone(); - match tauri::async_runtime::spawn_blocking(move || { - let state = flush_app.state::(); - flush_spotify_catalog(&state) - }) - .await - { - Ok(Ok(())) => {} - Ok(Err(error)) => { - log::warn!("Could not persist Spotify catalog: {error}"); - } - Err(error) => { - log::warn!("Spotify catalog persistence task failed: {error}"); - } - } - } - }); - *app - .state::() - .catalog_flush_task - .lock() - .expect("catalog flush-task mutex poisoned") = Some(catalog_task); - let backfill_app = app.handle().clone(); + let handle = app.handle().clone(); + let completion = startup_completion.clone(); tauri::async_runtime::spawn(async move { - let event_app = backfill_app.clone(); - match tauri::async_runtime::spawn_blocking(move || { - backfill_app - .state::() - .library - .mutate(|library| Ok(localfiles::backfill_metadata(library))) - }) - .await - { - Ok(Ok(true)) => { - let _ = emit_main(&event_app, "library-changed", ()); - } - Ok(Ok(false)) => {} - Ok(Err(error)) => notify_error(&event_app, error), - Err(error) => notify_error(&event_app, error.to_string()), - } - }); - let lastfm_startup = Arc::clone(&lastfm); - let profile_app = app.handle().clone(); - tauri::async_runtime::spawn(async move { - let import_hydration = async { - let result = lastfm_import_startup.hydrate().await; - if result.is_ok() { - let _ = profile_app.emit_to("main", "lastfm-import-changed", ()); - let _ = profile_app.emit_to( - "lastfm-importer", - "lastfm-import-changed", - (), - ); + match start_startup_load(app_data_dir.clone()).await { + Ok(files) => { + let main_handle = handle.clone(); + let main_completion = completion.clone(); + if let Err(error) = handle.run_on_main_thread(move || { + let result = finish_startup(&main_handle, app_data_dir, files) + .map_err(|error| error.to_string()); + main_completion.send_replace(Some(result)); + }) { + completion.send_replace(Some(Err(error.to_string()))); + } } - result - }; - let (lastfm_result, import_result) = - tokio::join!(lastfm_startup.hydrate(), import_hydration); - if let Err(error) = &lastfm_result { - notify_error(&profile_app, error.clone()); - } - if let Err(error) = &import_result { - notify_error(&profile_app, error.clone()); - } - lastfm_startup.set_enabled(lastfm_enabled).await; - let _ = settings_commands::set_lastfm_scrobbling(&profile_app, lastfm_enabled).await; - if import_result.is_ok() { - lastfm_import::resume_persisted_import(profile_app.clone()).await; - lastfm_import::resume_persisted_apply(profile_app.clone()).await; - let _ = lastfm_import_startup.backfill_completed_mappings().await; - if lastfm_result.is_ok() && lastfm_startup.state().await.connected { - let _ = - lastfm_import::commands::sync_lastfm_plays(profile_app.clone()).await; + Err(error) => { + completion.send_replace(Some(Err(error))); } } }); - let provider_app = app.handle().clone(); - let playback_effect_app = app.handle().clone(); - playback.listen( - move || provider_from(&provider_app.state::()), - move |effect| playback_commands::execute_effect(&playback_effect_app, effect), - ); - { - let handle = app.handle().clone(); - let credential_dir = app_data_dir.clone(); - tauri::async_runtime::spawn(async move { - let load_handle = handle.clone(); - let loaded = load_startup_credentials(move || { - match load_handle.state::().token_store.load() { - Ok(tokens) => Ok((ConnectionState::from_tokens(tokens), None)), - Err(retune_spotify::Error::TokenStoreCorrupt(error)) => { - let corrupt = quarantine_token_file( - &credential_dir, - use_dev_token_store, - unix_now(), - ) - .map_err(|error| error.to_string())?; - Ok(( - ConnectionState::from_tokens(None), - Some(format!( - "Retune could not load your Spotify credentials ({error}). The damaged file was moved to {}. Reconnect Spotify to continue.", - corrupt.display() - )), - )) - } - Err(error) => { - log::warn!("Token store unavailable at startup: {error}"); - Ok((ConnectionState::from_tokens(None), None)) - } - } - }) - .await; - let (connection, notice) = match loaded { - Ok(Ok(loaded)) => loaded, - Ok(Err(error)) => { - notify_error(&handle, error); - (ConnectionState::from_tokens(None), None) - } - Err(error) => { - notify_error(&handle, error.to_string()); - (ConnectionState::from_tokens(None), None) - } - }; - if let Some(notice) = notice { - log::warn!("{notice}"); - let _ = emit_main_event( - &handle, - main_events::MainEvent::StartupNotice(notice), - ); - } - if let Err(error) = emit_connection_state_async(&handle).await { - notify_error(&handle, error); - } - let startup_action = startup_action( - &connection, - &startup_client_id, - startup_auto_connect, - startup_last_full_sync, - startup_next_spotify_sync, - startup_cooldown, - unix_now(), - ); - if startup_action == StartupAction::Nothing && connection.connected { - if let Some(deadline) = effective_sync_deadline( - scheduled_sync_deadline( - startup_next_spotify_sync, - startup_last_full_sync, - ), - startup_cooldown, - unix_now(), - ) { - spotify_commands::schedule_auto_resume(&handle, deadline); - } - } - let activate_local = connection.connected - && connection.playback_authorized - && startup_backend == PlaybackBackend::Local; - if activate_local { - let state = handle.state::(); - if let Err(error) = switch_to_local(&state, initial_volume).await { - notify_error(&handle, error); - } - } - let result = match startup_action { - StartupAction::Sync => sync_spotify(&handle).await, - StartupAction::Connect => spotify_commands::connect_spotify(handle.clone()).await, - StartupAction::Nothing => match provider_from(&handle.state::()) { - Ok(client) => { - playlist_commands::sync_playlists(&handle, client.as_ref()).await - } - Err(error) => Err(error), - }, - }; - if let Err(error) = result { - notify_error(&handle, error); - } - }); - } Ok(()) }) .build(tauri::generate_context!()) @@ -1490,13 +1593,27 @@ pub fn run() { handle_local_drag_event(app, &label, &event); } tauri::RunEvent::Resumed if ready => { - let playback = Arc::clone(&app.state::().playback); + let Some(state) = app.try_state::() else { + return; + }; + let playback = Arc::clone(&state.playback); tauri::async_runtime::spawn(async move { playback.invalidate_local().await; }); } tauri::RunEvent::ExitRequested { api, .. } => { - let state = app.state::(); + let Some(state) = app.try_state::() else { + if startup_ready.borrow().is_none() { + api.prevent_exit(); + let handle = app.clone(); + let ready = startup_ready.clone(); + tauri::async_runtime::spawn(async move { + let _ = await_startup(ready).await; + handle.exit(0); + }); + } + return; + }; match exit_action(&state.shutdown_state) { ExitAction::Allow => return, ExitAction::WaitForDrain => { @@ -1577,6 +1694,79 @@ fn startup_action( mod tests { use std::collections::BTreeMap; + #[tokio::test] + async fn startup_gate_waits_for_authoritative_success_and_preserves_failure() { + let (complete, ready) = tokio::sync::watch::channel(None); + let mut pending = Box::pin(super::await_startup(ready.clone())); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(1), &mut pending) + .await + .is_err() + ); + complete.send_replace(Some(Ok(()))); + assert_eq!(pending.await, Ok(())); + complete.send_replace(Some(Err("recovery conflict".into()))); + assert_eq!( + super::await_startup(ready).await, + Err("recovery conflict".into()) + ); + let (_, abandoned) = tokio::sync::watch::channel(None); + assert!(super::await_startup(abandoned).await.is_err()); + } + + #[tokio::test] + async fn startup_file_recovery_is_off_caller_thread_and_preserves_corrupt_evidence() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("library.json"), "invalid library").unwrap(); + let loaded = super::start_startup_load(dir.path().to_path_buf()) + .await + .unwrap(); + assert!(loaded.library.tracks().is_empty()); + assert!(loaded.recovery_notice.is_some()); + assert!(std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(Result::ok) + .any(|entry| std::fs::read_to_string(entry.path()) + .is_ok_and(|bytes| bytes == "invalid library"))); + let (complete, ready) = tokio::sync::watch::channel(None); + let path = dir.path().to_path_buf(); + std::fs::write(path.join("settings.json"), "invalid settings").unwrap(); + complete.send_replace(Some( + super::start_startup_load(path.clone()).await.map(|_| ()), + )); + assert!(super::await_startup(ready).await.is_err()); + assert_eq!( + std::fs::read_to_string(path.join("settings.json")).unwrap(), + "invalid settings" + ); + } + + #[tokio::test] + #[ignore = "manual startup filesystem benchmark; generated fixture only"] + async fn startup_files_performance() { + let source = std::env::var_os("RETUNE_PERF_FIXTURE").expect("generated fixture directory"); + for sample in 1..=3 { + let dir = tempfile::tempdir().unwrap(); + for name in ["library.json", "settings.json"] { + std::fs::copy( + std::path::Path::new(&source).join(name), + dir.path().join(name), + ) + .unwrap(); + } + let started = std::time::Instant::now(); + let pending = super::start_startup_load(dir.path().to_path_buf()); + let dispatch_ms = started.elapsed().as_secs_f64() * 1000.; + let loaded = pending.await.unwrap(); + let ready_ms = started.elapsed().as_secs_f64() * 1000.; + assert_eq!(loaded.library.tracks().len(), 4000); + println!( + "{}", + serde_json::json!({"sample":sample,"dispatchMs":dispatch_ms,"readyMs":ready_ms,"tracks":loaded.library.tracks().len()}) + ); + } + } + #[tokio::test] async fn playback_effect_shutdown_drains_queued_work_and_is_idempotent() { let (sender, mut receiver) = tokio::sync::mpsc::channel(2); diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs index ff8bf5f..e4e1b0a 100644 --- a/apps/desktop/src-tauri/src/store.rs +++ b/apps/desktop/src-tauri/src/store.rs @@ -1407,6 +1407,15 @@ impl FsSettingsStore { } } + pub fn load_for_startup(&self) -> StoreResult { + if let Some(settings) = self.load()? { + return Ok(settings); + } + let settings = Settings::default(); + self.save(&settings)?; + Ok(settings) + } + pub fn save(&self, settings: &Settings) -> StoreResult<()> { #[cfg(test)] if let Some(hook) = self.save_hook.lock().unwrap().take() { @@ -1702,6 +1711,44 @@ mod tests { use super::*; use crate::fixture; + #[test] + fn startup_settings_create_missing_defaults_without_rewriting_existing_or_invalid_files() { + let dir = tempfile::tempdir().unwrap(); + let store = FsSettingsStore::new(dir.path()); + assert_eq!(store.load_for_startup().unwrap(), Settings::default()); + assert_eq!(store.load().unwrap(), Some(Settings::default())); + let existing = serde_json::to_vec_pretty(&Settings::default()).unwrap(); + fs::write(&store.path, &existing).unwrap(); + assert_eq!(store.load_for_startup().unwrap(), Settings::default()); + assert_eq!(fs::read(&store.path).unwrap(), existing); + fs::write(&store.path, b"invalid settings").unwrap(); + assert!(store.load_for_startup().is_err()); + assert_eq!(fs::read(&store.path).unwrap(), b"invalid settings"); + } + + #[test] + #[ignore = "manual startup settings performance experiment"] + fn startup_settings_performance() { + for sample in 1..=3 { + let dir = tempfile::tempdir().unwrap(); + let store = FsSettingsStore::new(dir.path()); + let start = std::time::Instant::now(); + std::hint::black_box(store.load_for_startup().unwrap()); + let missing_ms = start.elapsed().as_secs_f64() * 1000.0; + let start = std::time::Instant::now(); + for _ in 0..100 { + std::hint::black_box(store.load_for_startup().unwrap()); + } + println!( + "TAURI_PERF {}", + serde_json::json!({ + "scenario": "startup-settings", "sample": sample, "iterations": 100, + "missingMs": missing_ms, "existingTotalMs": start.elapsed().as_secs_f64() * 1000.0, + }) + ); + } + } + #[test] fn delayed_user_patch_preserves_newer_sync_bookkeeping_in_memory_and_on_disk() { let dir = tempfile::tempdir().unwrap(); diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index e056c73..edc2e59 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -310,8 +310,10 @@ button.spotify-page-art:focus-visible { outline: 2px solid var(--accent); outlin .playlist-unavailable button { padding: 5px 12px; border: 1px solid var(--border); border-radius: 4px; color: var(--text); background: var(--control); } .playlist-unavailable button:first-child { border-color: var(--accent); color: #fff; background: var(--accent); } .playlist-track-row { height: 20px; border-top: 2px solid transparent; white-space: nowrap; } -.zebra .playlist-track-row:nth-child(even) { background: var(--alt); } -.playlist-track-row.selected, .zebra .playlist-track-row.selected:nth-child(even) { color: #fff; background: var(--accent); } +.playlist-track-window { position: relative; min-width: 620px; } +.playlist-track-window > .playlist-track-row { position: absolute; width: 100%; } +.zebra .playlist-track-row.alternate { background: var(--alt); } +.playlist-track-row.selected, .zebra .playlist-track-row.selected.alternate { color: #fff; background: var(--accent); } .playlist-track-row.selected > :first-child, .playlist-track-row.selected time { color: #fff; } .playlist-track-row > * { min-width: 0; padding: 0 3px; overflow: hidden; text-overflow: ellipsis; } .playlist-track-row > :first-child, .playlist-track-row time { color: var(--dim); font-weight: 400; } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 813a6e6..37b68b0 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,6 +1,6 @@ import { listen } from '@tauri-apps/api/event' import { getCurrentWindow } from '@tauri-apps/api/window' -import { Fragment, useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react' import './App.css' import { appliedZoom, beginPendingEntity, beginRequestGeneration, browseFacetValues, browseRequestKey, browseTypeaheadContextKey, browseViewForRequest, cancelTrackInfoLoad, COLUMN_SPECS, compareTracks, contiguousRange, currentPlaybackAuthorization, currentPlaylistRows, DRAG_LOCAL_TYPE, DRAG_TYPE, entityRequestGeneration, facetLabel, failedPlaylistRows, formatTime, hasLocalTracks, insertionIndexAtY, isCurrentRequestGeneration, isCurrentTrack, labels, loadArtwork, loadCurrentGeneration, loadingPlaylistRows, moveBefore, moveToIndex, normalizeZoom, pendingEntities, pendingPlaybackTarget, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistLayoutFor, playlistOverride, playlistRows, playlistRowsReady, PLAYLIST_COLUMNS, PLAYLIST_DEFAULT_COLUMN_ORDER, PLAYLIST_DEFAULT_HIDDEN_COLUMNS, resolvedPlaylistRows, resizedColumnWidth, routeGlobalShortcut, selectionAfterFacet, simulatedPlaybackTick, staleSelectionFacet, SYNTHETIC_BASE, trackColumnHeadings, trackGridColumns, visibleColumnOrder } from './ui.ts' import { defaultSettings, initialState, reducer, type Action, type State } from './appState.ts' @@ -12,6 +12,8 @@ import { ArtworkLightbox, CheckboxMenu, ContextMenu, ModalDialog } from './viewS import { importDownloadPercent, importDownloadProgressLabel, importStatusText } from './lastfmImportState.ts' import { libraryEvents, libraryGateway } from './libraryGateway.ts' import { playbackEvents, playbackGateway } from './playbackGateway.ts' +import { createPlaybackProgress, documentVisible, onlyPlaybackProgressChanged, subscribeDocumentVisibility } from './playbackProgress.ts' +import { useTrackWindow } from './useTrackWindow.ts' import { spotifyEvents, spotifyGateway } from './spotifyGateway.ts' import { dispatchMainEvent, subscribeInvalidationThenSnapshot, subscribeMainEvents, type MainEventHandlers, type SpotifyPlayRequest } from './ipc.ts' import { appGateway } from './appGateway.ts' @@ -54,6 +56,7 @@ function useTauriInvalidationSnapshot(event: string, snapshot: () => Promise< } function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Playing | null, dispatch: React.Dispatch) { + const [progress] = useState(createPlaybackProgress) const queue = useRef(emptyTracks) const origin = useRef(undefined) const pendingPlay = useRef<{ id: number; tracks: readonly PlaybackTrack[]; origin?: PlaybackOrigin; awaitingPlaybackAuthorization: boolean } | null>(null) @@ -64,8 +67,13 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla const onState = useCallback((player: import('./types.ts').PlayerState) => { if (player.external) origin.current = undefined + progress.update(player.elapsed) + const current = playingRef.current + if (onlyPlaybackProgressChanged(current, player) + && (player.external || current?.queue === queue.current) + && current?.origin === origin.current) return dispatch({ type: 'playerState', player, queue: queue.current, origin: origin.current }) - }, [dispatch]) + }, [dispatch, progress]) const onAuthorizationRequired = useCallback((prompt: import('./types.ts').PlaybackAuthorizationPrompt) => { const id = pendingPlaybackTarget(prompt, queue.current) @@ -173,8 +181,8 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla useEffect(() => () => window.clearTimeout(volumeTimer.current), []) return useMemo( - () => ({ start, toggle, playRequestedTrack, step, setVolume, seek, cancelPending, onState, onAuthorizationRequired }), - [cancelPending, onAuthorizationRequired, onState, playRequestedTrack, seek, setVolume, start, step, toggle], + () => ({ start, toggle, playRequestedTrack, step, setVolume, seek, cancelPending, onState, onAuthorizationRequired, progress }), + [cancelPending, onAuthorizationRequired, onState, playRequestedTrack, progress, seek, setVolume, start, step, toggle], ) } @@ -655,6 +663,7 @@ function App() {
(null) const inner = useRef(null) const [distance, setDistance] = useState(0) @@ -878,7 +888,7 @@ function Marquee({ text, strong }: { text: string; strong?: boolean }) { 0 ? ' scrolling' : ''}${strong ? ' strong' : ''}`} - style={distance > 0 ? { '--marquee-distance': `-${distance}px`, animationDuration: `${Math.max(8, Math.round(distance / 12))}s` } as React.CSSProperties : undefined} + style={distance > 0 ? { '--marquee-distance': `-${distance}px`, animationDuration: `${Math.max(8, Math.round(distance / 12))}s`, animationPlayState: visible ? 'running' : 'paused' } as React.CSSProperties : undefined} >{text} } @@ -920,8 +930,11 @@ function TrackArtworkLightbox({ uri, name, onClose }: { uri: string; name: strin return } -export function TransportBar({ playing, track, query, queryReset, scope, volume, searchRef, onQuery, onScope, onPlay, onPrev, onNext, onVolume, onSeek, onOrigin, onArtwork, onContextMenu }: { +const noProgressSubscription = () => () => {} + +export function TransportBar({ playing, progress, track, query, queryReset, scope, volume, searchRef, onQuery, onScope, onPlay, onPrev, onNext, onVolume, onSeek, onOrigin, onArtwork, onContextMenu }: { playing: State['playing']; track?: PlaybackTrack; query: string; queryReset?: number; scope: State['scope'] + progress?: ReturnType volume: number searchRef: React.RefObject onQuery: (query: string) => void; onScope: (scope: State['scope']) => void; onSeek: (seconds: number) => void @@ -943,7 +956,8 @@ export function TransportBar({ playing, track, query, queryReset, scope, volume, window.clearTimeout(queryTimer.current) if (next !== query) onQuery(next) } - const elapsed = playing?.elapsed ?? 0 + const nativeElapsed = useSyncExternalStore(progress?.subscribe ?? noProgressSubscription, progress?.getSnapshot ?? (() => playing?.elapsed ?? 0)) + const elapsed = !playing ? 0 : playing.simulated ? playing.elapsed : nativeElapsed const shown = playing?.external ? { name: `${playing.name ?? 'Unknown Track'} (Spotify)`, art: playing.art ?? '', @@ -1242,11 +1256,13 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, window.addEventListener('keydown', selectAll) return () => window.removeEventListener('keydown', selectAll) }, [tracks]) - const rows = playlistRows(tracks, sortColumn, sortDesc) + const rows = useMemo(() => playlistRows(tracks, sortColumn, sortDesc), [tracks, sortColumn, sortDesc]) const rovingUpstreamIndex = rows.some((row) => row.upstreamIndex === focusedUpstreamIndex) ? focusedUpstreamIndex : rows.find((row) => selected.has(row.upstreamIndex))?.upstreamIndex ?? rows[0]?.upstreamIndex - const queue: PlaybackTrack[] = rows.map(({ track, upstreamIndex }) => ({ ...track, id: track.id ?? SYNTHETIC_BASE + upstreamIndex })) + const queue = useMemo(() => rows.map(({ track, upstreamIndex }) => ({ ...track, id: track.id ?? SYNTHETIC_BASE + upstreamIndex })), [rows]) + const rowHeight = 20 + const { scroll, readViewport, reveal, indices: rowIndices } = useTrackWindow(rows.length, rowHeight, rows.findIndex(row => row.upstreamIndex === rovingUpstreamIndex)) const headings = { ...trackColumnHeadings(labels.music), track: 'Track' } const customizableColumns = columnOrder.filter((column) => PLAYLIST_COLUMNS.includes(column)) const visibleColumns = visibleColumnOrder(customizableColumns, hiddenColumns) @@ -1383,8 +1399,8 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing,
{playlist.name}{playlist.trackCount} {playlist.trackCount === 1 ? 'track' : 'tracks'}{playlist.owner ? ` · by ${playlist.owner}` : ''}{sortColumn ? ` · sorted by ${headings[sortColumn]}` : ''}{playlist.owned && }
{!playlist.itemsAvailable ?
Tracks unavailable in RetuneSpotify does not allow third-party apps to interact with playlists not owned by you. :-(
: <> -
{ - if (event.target !== event.currentTarget && !(event.target as Element).closest('.playlist-end-drop')) return +
{ + if (event.target !== event.currentTarget && !(event.target as Element).matches('.playlist-end-drop, .playlist-track-window')) return setSelected(new Set()) setSelectionAnchor(undefined) }}> @@ -1410,14 +1426,19 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, event.stopPropagation() }} />)}
- {rows.map(({ track, upstreamIndex }, rowIndex) =>
{rowIndices.map(rowIndex => { + const { track, upstreamIndex } = rows[rowIndex] + return
setFocusedUpstreamIndex(upstreamIndex)} onClick={(event) => { if (suppressTrackClick.current) { suppressTrackClick.current = false; event.preventDefault(); return } @@ -1442,11 +1463,14 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, const next = rows[nextIndex] if (!next) return select(next.upstreamIndex, event) + setFocusedUpstreamIndex(next.upstreamIndex) + reveal(nextIndex) const rowContainer = event.currentTarget.parentElement - window.requestAnimationFrame(() => rowContainer?.querySelectorAll('.playlist-track-row')[nextIndex]?.focus()) + window.requestAnimationFrame(() => rowContainer?.querySelector(`[data-upstream-index="${next.upstreamIndex}"]`)?.focus()) }} onPointerDown={canReorder && !mutating ? (event) => { if (event.button !== 0) return + setFocusedUpstreamIndex(upstreamIndex) suppressTrackClick.current = false trackDrag.current = { indices: selected.has(upstreamIndex) ? [...selected] : [upstreamIndex], pointerId: event.pointerId, startY: event.clientY, moved: false } event.currentTarget.setPointerCapture(event.pointerId) @@ -1461,8 +1485,8 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, } drag.moved = true event.preventDefault() - const trackRows = [...event.currentTarget.parentElement!.querySelectorAll('.playlist-track-row')] - const target = insertionIndexAtY(trackRows.map((row) => { const bounds = row.getBoundingClientRect(); return bounds.top + bounds.height / 2 }), event.clientY) + const bounds = event.currentTarget.parentElement!.getBoundingClientRect() + const target = Math.max(0, Math.min(rows.length, Math.floor((event.clientY - bounds.top) / (bounds.height / rows.length) + 0.5))) dragInsertBefore.current = target setInsertBefore(target) } : undefined} @@ -1486,7 +1510,8 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, if (!selected.has(upstreamIndex)) select(upstreamIndex, event) setMenu({ x: event.clientX, y: event.clientY, upstreamIndex }) }} - >{upstreamIndex + 1}{visibleColumns.map((column) => )}
)} + >{upstreamIndex + 1}{visibleColumns.map((column) => )}
+ })}
{canReorder &&
}
{menu && (menu.upstreamIndex === undefined diff --git a/apps/desktop/src/LastFmImporter.tsx b/apps/desktop/src/LastFmImporter.tsx index ac6c7bf..8945086 100644 --- a/apps/desktop/src/LastFmImporter.tsx +++ b/apps/desktop/src/LastFmImporter.tsx @@ -12,6 +12,7 @@ import { appGateway } from './appGateway.ts' import { activeImportQueue, applyCurrentImportPageResponse, applyCurrentImportRefresh, beginImportRefresh, canHandleImportShortcut, collectionAlbumActionLabel, collectionAlbumTrackStatuses, collectionAmbiguousChoices, collectionCoverageStatus, collectionDialogInitialState, collectionDialogScreen, collectionDialogTransition, collectionPreviewCoverageCopy, collectionSuggestion, downloadAction, excludeImportRows, excludedImportCount, filterImportQueue, handleImportQueueTab, importAlbumActionAdvances, importApplyErrorCode, importCountMergePresentation, importDownloadCopy, importDownloadPercent, importEmptyPageMessage, importQueueHighlightIndex, importQueueTabTarget, importQueueVisibleRange, importStatusText, isCurrentImportPageResponse, isCurrentImportRefresh, loadSelectedImportPage, mergeReviewBatchDraft, moveImportNavigationRow, moveImportQueueIndex, nextRemainingImportQueue, parseImportApplyResult, pickerCandidates, pickerSelectedUri, projectAcknowledgedImportApply, projectImportQueueExclusion, runCheckedImportMutation, selectImportRows, selectedCollectionAlbumUris, selectedImportTrackConfidence, shouldRefreshImportEvent, showsImportRemaining, sortImportQueue, spotifyLimitCountdown, stablePartitionImportRows, strongImportAlbumMatch, trackPickerQuery, validImportIntent, type CollectionAmbiguousChoice, type CollectionTrackStatusProjection, type CountMode, type ImportApplyErrorCode, type ImportConfidence, type ImportNavigationTarget, type ImportPickerKind, type ImportQueueItem, type ImportQueueTabTarget, type ImportRowSelection, type ImportSourceRow, type ReviewBatchKey, type ReviewState } from './lastfmImportState.ts' import { formatTime } from './ui.ts' import './lastfmImporter.css' +import { loadImportQueue } from './loadImportQueue.ts' type ImportStateView = LastFmImportState type PickerKind = ImportPickerKind @@ -22,7 +23,6 @@ const IMPORT_NAV_KEYS = 'ArrowUp ArrowDown Tab Shift+Tab Enter E Space X S A Esc const emptyDefaults: LastFmImportDefaults = { importContent: true, includeHistoricalPlayCounts: true, wholeAlbum: false } const emptyState: ImportStateView = { phase: null, username: null, spotifyAccountId: null, historyTo: null, downloadedThrough: null, nextPage: 1, totalPages: null, downloadedPages: 0, totalScrobbles: 0, includedScrobbles: 0, processedScrobbles: 0, defaults: emptyDefaults, remaining: 0, retryableError: null, searchTerms: true, syncing: false, lastSyncedAt: null, pendingReview: 0, syncProblem: 'Retune is still loading Last.fm import state.', applyingAll: false, spotifyLimit: null } -const importQueuePageLimit = 1000 const invalidApplyResultMessage = 'Retune received an invalid Last.fm import result.' type DisplayError = { message: string; code: ImportApplyErrorCode; retryAt: number | null } @@ -44,27 +44,6 @@ function SpotifyLimitNotice({ code, retryAt }: { code: ImportApplyErrorCode; ret return {now >= retryAt ? 'Spotify’s reported wait has ended; retry now.' : <>Available again · {spotifyLimitCountdown(retryAt, now)} remaining} } -async function loadImportQueue(): Promise { - for (let attempt = 0; attempt < 2; attempt += 1) { - const items: ImportQueueItem[] = [] - let cursor = 0 - let total: number | undefined - while (true) { - const page = await lastfmGateway.queue(cursor, importQueuePageLimit) - if (page.cursor !== cursor || page.items.length > importQueuePageLimit) throw new Error('Last.fm import queue pagination is invalid.') - if (total !== undefined && page.total !== total) break - total ??= page.total - items.push(...page.items) - if (page.nextCursor === null) { - if (items.length === page.total) return items - break - } - if (!Number.isSafeInteger(page.nextCursor) || page.nextCursor <= cursor || page.nextCursor > page.total) throw new Error('Last.fm import queue pagination is invalid.') - cursor = page.nextCursor - } - } - throw new Error('Last.fm import queue changed while it was loading. Please retry.') -} function reviewForPage(page: PageView): ReviewState { const rows = page.rows.map((item) => item.source) @@ -1148,7 +1127,7 @@ export default function LastFmImporter() { const currentSelectedPage = selectedPageRef.current const currentSort = sortRef.current try { - const { value: [nextState, nextQueue], applied } = await applyCurrentImportRefresh(requestGeneration, refreshGeneration, Promise.all([lastfmGateway.state(), loadImportQueue()]), ([currentState, currentQueue]) => { + const { value: [nextState, nextQueue], applied } = await applyCurrentImportRefresh(requestGeneration, refreshGeneration, Promise.all([lastfmGateway.state(), loadImportQueue(() => isCurrentImportRefresh(requestGeneration, refreshGeneration))]), ([currentState, currentQueue]) => { setState(currentState) setShowQueries(currentState.searchTerms) setQueue(currentQueue) @@ -1195,7 +1174,7 @@ export default function LastFmImporter() { const refreshQueueOnly = useCallback(async (strict = false): Promise => { const requestGeneration = beginImportRefresh(refreshGeneration) try { - const { value: [, nextQueue] } = await applyCurrentImportRefresh(requestGeneration, refreshGeneration, Promise.all([lastfmGateway.state(), loadImportQueue()]), ([nextState, currentQueue]) => { + const { value: [, nextQueue] } = await applyCurrentImportRefresh(requestGeneration, refreshGeneration, Promise.all([lastfmGateway.state(), loadImportQueue(() => isCurrentImportRefresh(requestGeneration, refreshGeneration))]), ([nextState, currentQueue]) => { setState(nextState) setShowQueries(nextState.searchTerms) setQueue(currentQueue) diff --git a/apps/desktop/src/libraryViews.tsx b/apps/desktop/src/libraryViews.tsx index 99af865..612e1c6 100644 --- a/apps/desktop/src/libraryViews.tsx +++ b/apps/desktop/src/libraryViews.tsx @@ -1,7 +1,8 @@ -import { useCallback, useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from 'react' import type { BrowseView, ColumnKey, Playing, PlaylistSubject, Selection, Settings, Source, Track } from './types.ts' import { browseFacetValues, COLUMN_SPECS, DRAG_LOCAL_TYPE, DRAG_TYPE, facetLabel, formatTime, hasLocalTracks, isCurrentTrack, labels, moveBefore, resizedColumnWidth, resizedPaneHeight, trackColumnHeadings, trackGridColumns, visibleColumnOrder } from './ui.ts' import { CheckboxMenu, ContextMenu, RatingStars } from './viewShared.tsx' +import { useTrackWindow } from './useTrackWindow.ts' export function BrowserPane({ state, anchors, onActivate, onSelect, onPlay, onToggle, onPrefix, onNavigate }: { state: { source: Source; view: BrowseView | null; viewKey?: string; browseKey: string; settings: Pick; sel: Selection } @@ -158,36 +159,11 @@ export function TrackList({ tracks, label, selectedIds, selectionAnchor, playing const [liveWidths, setLiveWidths] = useState(columnWidths) const [menu, setMenu] = useState<{ x: number; y: number; trackId?: number }>() const [focusedTrackId, setFocusedTrackId] = useState() - const scroll = useRef(null) - const [viewport, setViewport] = useState({ top: 0, height: 600 }) - // Matches the fixed library row height; scrollTop/clientHeight are unscaled by CSS zoom. const rowHeight = 18 - const overscan = 12 - const visibleCount = Math.ceil(viewport.height / rowHeight) + overscan * 2 - const first = Math.max(0, Math.min(Math.floor(viewport.top / rowHeight) - overscan, tracks.length - visibleCount)) - const last = Math.min(tracks.length, first + visibleCount) - const readViewport = useCallback(() => { - const element = scroll.current - if (element) setViewport((current) => { - const next = { top: element.scrollTop, height: element.clientHeight || 600 } - return current.top === next.top && current.height === next.height ? current : next - }) - }, []) - useLayoutEffect(() => { - readViewport() - const observer = new ResizeObserver(readViewport) - if (scroll.current) observer.observe(scroll.current) - return () => observer.disconnect() - }, [readViewport]) - const reveal = (index: number) => { - const element = scroll.current - if (!element || index < 0) return - const top = index * rowHeight - const bottom = top + rowHeight + (element.firstElementChild as HTMLElement).offsetHeight - if (top < element.scrollTop) element.scrollTop = top - else if (bottom > element.scrollTop + element.clientHeight) element.scrollTop = Math.max(0, bottom - (element.clientHeight || 600)) - readViewport() - } + const rovingTrackId = tracks.some((track) => track.id === focusedTrackId) + ? focusedTrackId + : tracks.find((track) => selectedIds.has(track.id))?.id ?? tracks[0]?.id + const { scroll, readViewport, reveal, indices: rowIndices } = useTrackWindow(tracks.length, rowHeight, tracks.findIndex(track => track.id === rovingTrackId)) const revealSelection = useEffectEvent((id: number) => { const index = tracks.findIndex((track) => track.id === id) if (index >= 0) { reveal(index); setFocusedTrackId(id) } @@ -202,14 +178,6 @@ export function TrackList({ tracks, label, selectedIds, selectionAnchor, playing const headings = trackColumnHeadings(label) const visibleColumns = visibleColumnOrder(columnOrder, hiddenColumns) const columns = trackGridColumns(visibleColumns, liveWidths) - const rovingTrackId = tracks.some((track) => track.id === focusedTrackId) - ? focusedTrackId - : tracks.find((track) => selectedIds.has(track.id))?.id ?? tracks[0]?.id - const rowIndices = Array.from({ length: last - first }, (_, index) => first + index) - const focusedIndex = tracks.findIndex((track) => track.id === rovingTrackId) - // Keep the keyboard's current row mounted even when it scrolls out of view. - if (focusedIndex >= 0 && (focusedIndex < first || focusedIndex >= last)) rowIndices.push(focusedIndex) - rowIndices.sort((left, right) => left - right) const moveColumn = (event: React.PointerEvent) => { const active = columnDrag.current if (!active || active.pointerId !== event.pointerId) return diff --git a/apps/desktop/src/loadImportQueue.ts b/apps/desktop/src/loadImportQueue.ts new file mode 100644 index 0000000..081f61f --- /dev/null +++ b/apps/desktop/src/loadImportQueue.ts @@ -0,0 +1,30 @@ +import { lastfmGateway } from './lastfmGateway.ts' +import type { ImportQueueItem } from './lastfmImportState.ts' + +const importQueuePageLimit = 1000 + +// ponytail: the current refresh still loads all rows for global sort/selection; use revisioned snapshots if steady refresh cost matters. +export async function loadImportQueue(isCurrent: () => boolean): Promise { + for (let attempt = 0; attempt < 2; attempt += 1) { + const items: ImportQueueItem[] = [] + let cursor = 0 + let total: number | undefined + while (true) { + if (!isCurrent()) return [] + const page = await lastfmGateway.queue(cursor, importQueuePageLimit) + if (!isCurrent()) return [] + if (page.cursor !== cursor || page.items.length > importQueuePageLimit) throw new Error('Last.fm import queue pagination is invalid.') + if (total !== undefined && page.total !== total) break + total ??= page.total + items.push(...page.items) + if (page.nextCursor === null) { + if (items.length === page.total) return items + break + } + if (!Number.isSafeInteger(page.nextCursor) || page.nextCursor <= cursor || page.nextCursor > page.total) throw new Error('Last.fm import queue pagination is invalid.') + cursor = page.nextCursor + } + } + throw new Error('Last.fm import queue changed while it was loading. Please retry.') +} + diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 494d432..aeea30e 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -2,13 +2,17 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { getCurrentWindow } from '@tauri-apps/api/window' import './index.css' -import App from './App.tsx' -import LastFmImporter from './LastFmImporter.tsx' +// Theme and shared dialogs are used by both windows. +import './App.css' const isImporter = getCurrentWindow().label === 'lastfm-importer' -createRoot(document.getElementById('root')!).render( - - {isImporter ? : } - , -) +const root = createRoot(document.getElementById('root')!) +root.render(
Loading Retune…
) +// Keep separate loader functions so Vite associates each chunk with its own CSS. +const entries = { main: () => import('./App.tsx'), importer: () => import('./LastFmImporter.tsx') } +entries[isImporter ? 'importer' : 'main']().then(({ default: WindowView }) => root.render()) + .catch(error => { + console.error('Could not load Retune window', error) + root.render(
Couldn’t load Retune.
) + }) diff --git a/apps/desktop/src/playbackProgress.ts b/apps/desktop/src/playbackProgress.ts new file mode 100644 index 0000000..4f9e030 --- /dev/null +++ b/apps/desktop/src/playbackProgress.ts @@ -0,0 +1,31 @@ +import type { PlayerState, Playing } from './types.ts' + +export const documentVisible = () => document.visibilityState !== 'hidden' +export function subscribeDocumentVisibility(listener: () => void) { + document.addEventListener('visibilitychange', listener) + return () => document.removeEventListener('visibilitychange', listener) +} + +// Native position is presentation data; semantic playback changes still use the reducer. +export function createPlaybackProgress() { + let elapsed = 0 + const listeners = new Set<() => void>() + return { + getSnapshot: () => elapsed, + subscribe: (listener: () => void) => { + listeners.add(listener) + const stop = subscribeDocumentVisibility(() => { if (documentVisible()) listener() }) + return () => { listeners.delete(listener); stop() } + }, + update: (next: number) => { + if (next === elapsed) return + elapsed = next + if (documentVisible()) listeners.forEach(listener => listener()) + }, + } +} + +export function onlyPlaybackProgressChanged(current: Playing | null, next: PlayerState) { + return current !== null && !current.simulated + && (Object.keys(next) as (keyof PlayerState)[]).every(key => key === 'elapsed' || current[key] === next[key]) +} diff --git a/apps/desktop/src/useTrackWindow.ts b/apps/desktop/src/useTrackWindow.ts new file mode 100644 index 0000000..41050cf --- /dev/null +++ b/apps/desktop/src/useTrackWindow.ts @@ -0,0 +1,38 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react' + +// Both lists have fixed CSS row heights; scroll offsets are unscaled by CSS zoom. +export function useTrackWindow(count: number, rowHeight: number, focusedIndex: number) { + const scroll = useRef(null) + const [viewport, setViewport] = useState({ top: 0, height: 600 }) + const overscan = 12 + const visibleCount = Math.ceil(viewport.height / rowHeight) + overscan * 2 + const first = Math.max(0, Math.min(Math.floor(viewport.top / rowHeight) - overscan, count - visibleCount)) + const last = Math.min(count, first + visibleCount) + const readViewport = useCallback(() => { + const element = scroll.current + if (element) setViewport(current => { + const next = { top: element.scrollTop, height: element.clientHeight || 600 } + return current.top === next.top && current.height === next.height ? current : next + }) + }, []) + useLayoutEffect(() => { + readViewport() + const observer = new ResizeObserver(readViewport) + if (scroll.current) observer.observe(scroll.current) + return () => observer.disconnect() + }, [count, readViewport]) + const reveal = (index: number) => { + const element = scroll.current + if (!element || index < 0) return + const top = index * rowHeight + const bottom = top + rowHeight + (element.firstElementChild as HTMLElement).offsetHeight + if (top < element.scrollTop) element.scrollTop = top + else if (bottom > element.scrollTop + element.clientHeight) element.scrollTop = Math.max(0, bottom - (element.clientHeight || 600)) + readViewport() + } + const indices = Array.from({ length: last - first }, (_, index) => first + index) + // Keep the keyboard/pointer's current row mounted when it leaves the viewport. + if (focusedIndex >= 0 && (focusedIndex < first || focusedIndex >= last)) indices.push(focusedIndex) + indices.sort((left, right) => left - right) + return { scroll, readViewport, reveal, indices } +} diff --git a/apps/desktop/test/importQueue.test.ts b/apps/desktop/test/importQueue.test.ts new file mode 100644 index 0000000..7c5ded9 --- /dev/null +++ b/apps/desktop/test/importQueue.test.ts @@ -0,0 +1,41 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { lastfmGateway } from '../src/lastfmGateway.ts' +import { loadImportQueue } from '../src/loadImportQueue.ts' +import { applyCurrentImportRefresh } from '../src/lastfmImportState.ts' +import type { ImportQueueItem } from '../src/lastfmImportState.ts' + +afterEach(() => vi.restoreAllMocks()) + +test('superseded queue loads stop after their pending page; newest load retains the complete queue', async () => { + const items = Array.from({ length: 2100 }, (_, index) => ({ page: index + 1 }) as ImportQueueItem) + let finishOld!: (value: Awaited>) => void + const page = (cursor: number) => ({ cursor, items: items.slice(cursor, cursor + 1000), total: items.length, nextCursor: cursor + 1000 < items.length ? cursor + 1000 : null }) + const queue = vi.spyOn(lastfmGateway, 'queue').mockImplementationOnce(() => new Promise(resolve => { finishOld = resolve })) + .mockImplementation(async cursor => page(cursor)) + const generation = { current: 1 } + const apply = vi.fn() + const old = applyCurrentImportRefresh(1, generation, loadImportQueue(() => generation.current === 1), apply) + generation.current = 2 + const newest = loadImportQueue(() => generation.current === 2) + finishOld(page(0)) + expect((await old).applied).toBe(false) + expect(apply).not.toHaveBeenCalled() + expect(await newest).toEqual(items) + expect(queue.mock.calls.map(([cursor]) => cursor)).toEqual([0, 0, 1000, 2000]) + queue.mockClear() + expect(await loadImportQueue(() => false)).toEqual([]) + expect(queue).not.toHaveBeenCalled() +}) + +test('current queue loads retry changing totals and reject malformed or repeatedly changing pagination', async () => { + const item = { page: 1 } as ImportQueueItem + const queue = vi.spyOn(lastfmGateway, 'queue') + .mockResolvedValueOnce({ cursor: 0, items: [item], total: 2, nextCursor: 1 }) + .mockResolvedValueOnce({ cursor: 1, items: [item], total: 3, nextCursor: 2 }) + .mockResolvedValueOnce({ cursor: 0, items: [item], total: 1, nextCursor: null }) + expect(await loadImportQueue(() => true)).toEqual([item]) + queue.mockResolvedValue({ cursor: 0, items: [item], total: 2, nextCursor: 0 }) + await expect(loadImportQueue(() => true)).rejects.toThrow('pagination is invalid') + queue.mockResolvedValue({ cursor: 0, items: [item], total: 2, nextCursor: null }) + await expect(loadImportQueue(() => true)).rejects.toThrow('changed while it was loading') +}) diff --git a/apps/desktop/test/interactions.test.tsx b/apps/desktop/test/interactions.test.tsx index dbdb6be..a3cdd65 100644 --- a/apps/desktop/test/interactions.test.tsx +++ b/apps/desktop/test/interactions.test.tsx @@ -589,8 +589,9 @@ describe('mounted native interaction boundaries', () => { it('shares track-list actions with now playing and keeps the clicked track despite selection or playback changes', async () => { const tracks = [track(1, 'Playing'), track(2, 'Selected'), track(3, 'Also Selected'), { ...track(4, 'Local'), uri: 'file:///local.mp3', isLocal: true }] + const readTracks = vi.fn(() => tracks) const browse: BrowseView = { - facets: { cats: ['Rock'], arts: ['Artist'], albs: ['Album'] }, tracks, + facets: { cats: ['Rock'], arts: ['Artist'], albs: ['Album'] }, get tracks() { return readTracks() }, albumRating: null, albumRatingArtist: null, albumRatingAmbiguous: false, counts: { tracks: tracks.length, totalSecs: 720, perSource: { music: tracks.length, podcasts: 0, audiobooks: 0 } }, } @@ -628,6 +629,28 @@ describe('mounted native interaction boundaries', () => { expect(menuItems()).toHaveLength(0) await act(async () => view.querySelector('[data-track-id="1"]')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))) await waitFor(() => expect(view.querySelector('.lcd .marquee')?.textContent).toBe('Playing')) + readTracks.mockClear() + await act(async () => channel.onmessage({ type: 'playerState', payload: playerState(0, { elapsed: 11 }) })) + expect(view.querySelector('[aria-label="Playback position"]')?.value).toBe('11') + expect(readTracks).not.toHaveBeenCalled() + const visibility = Object.getOwnPropertyDescriptor(document, 'visibilityState') + try { + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }) + await act(async () => document.dispatchEvent(new Event('visibilitychange'))) + await act(async () => channel.onmessage({ type: 'playerState', payload: playerState(0, { elapsed: 42 }) })) + expect(view.querySelector('[aria-label="Playback position"]')?.value).toBe('11') + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }) + await act(async () => document.dispatchEvent(new Event('visibilitychange'))) + expect(view.querySelector('[aria-label="Playback position"]')?.value).toBe('42') + expect(readTracks).not.toHaveBeenCalled() + } finally { + if (visibility) Object.defineProperty(document, 'visibilityState', visibility) + else Reflect.deleteProperty(document, 'visibilityState') + } + await act(async () => channel.onmessage({ type: 'playerState', payload: playerState(0, { elapsed: 12, isPlaying: false }) })) + expect(view.querySelector('[aria-label="Play"]')).not.toBeNull() + expect(readTracks).toHaveBeenCalled() + await act(async () => channel.onmessage({ type: 'playerState', payload: playerState(0, { elapsed: 13 }) })) await act(async () => { view.querySelector('[data-track-id="2"]')!.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) @@ -635,6 +658,7 @@ describe('mounted native interaction boundaries', () => { view.querySelector('[data-track-id="3"]')!.dispatchEvent(new MouseEvent('click', { bubbles: true, metaKey: true })) }) expect(view.querySelectorAll('.track-row.selected')).toHaveLength(2) + expect(view.querySelector('[aria-label="Playback position"]')?.value).toBe('13') await contextMenu('[data-track-id="2"]') const listActions = menuItems().map((item) => item.textContent) await act(async () => menuItems()[4].click()) @@ -675,6 +699,9 @@ describe('mounted native interaction boundaries', () => { expect(menuItems()[3].disabled).toBe(true) await act(async () => menuItems()[0].click()) expect(invokeMock).toHaveBeenCalledWith('playlists_list', { uris: ['spotify:track:external'] }) + await act(async () => channel.onmessage({ type: 'playerState', payload: playerState(0, { trackId: null, uri: null, elapsed: 0, name: null, isPlaying: false }) })) + expect(view.querySelector('[aria-label="Playback position"]')?.value).toBe('0') + expect(view.querySelector('[aria-label="Playback position"]')?.disabled).toBe(true) }) it('clears whole-album mode when a release loses its album selection, including stale saved options', async () => { @@ -912,6 +939,54 @@ describe('mounted native interaction boundaries', () => { expect(view.querySelector('[data-track-id="1"]')?.tabIndex).toBe(0) }) + it('windows playlist rows while retaining duplicate positions, range selection, playback, and drag indices', async () => { + const tracks = Array.from({ length: 4000 }, (_, index) => ({ ...track(index, `Track ${index % 100}`), id: null, uri: `spotify:track:${index % 100}` })) + invokeMock.mockImplementation(async command => { + if (command === 'get_settings') return defaultSettings + if (command === 'connection_state') return { connected: true, needs_reauth: false, playback_authorized: true } + if (command === 'spotify_sync_status') return spotifyStatus() + if (command === 'lastfm_state') return { available: false, connected: false, username: null, pending: false, reconnectRequired: false, problem: null } + if (command === 'lastfm_import_state') return idleLastFmImport() + if (command === 'playlists_list') return [{ id: 'fixture', name: 'Fixture', owned: true, itemsAvailable: true, trackCount: tracks.length }] + if (command === 'playlist_tracks') return tracks + if (command === 'play_tracks') return 'started' + return null + }) + const view = await render() + await act(async () => view.querySelector('.playlist-row')!.click()) + const row = (index: number) => view.querySelector(`[data-upstream-index="${index}"]`)! + expect(view.querySelectorAll('.playlist-track-row').length).toBeLessThan(100) + expect(row(0).getAttribute('aria-setsize')).toBe('4000') + await act(async () => { row(0).focus(); key(row(0), 'End'); await new Promise(requestAnimationFrame) }) + expect(document.activeElement).toBe(row(3999)) + expect(row(3999).getAttribute('aria-posinset')).toBe('4000') + await act(async () => key(row(3999), 'Enter')) + const play = invokeMock.mock.calls.find(([command]) => command === 'play_tracks')![1]! + expect(play.startIndex).toBe(3999) + expect(play.resources).toHaveLength(4000) + const resources = play.resources as { id: number; uri: string }[] + expect(resources[3999].uri).toBe(resources[99].uri) + expect(resources[3999].id).not.toBe(resources[99].id) + await act(async () => { key(row(3999), 'Home', { shiftKey: true }); await new Promise(requestAnimationFrame) }) + expect(document.activeElement).toBe(row(0)) + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true) + try { + await act(async () => view.querySelector('.playlist-header button')!.click()) + expect(invokeMock).toHaveBeenCalledWith('playlist_remove', { id: 'fixture', indices: Array.from({ length: 4000 }, (_, index) => index) }) + } finally { confirm.mockRestore() } + const scroll = view.querySelector('.playlist-track-scroll')! + await act(async () => { scroll.scrollTop = 40_000; scroll.dispatchEvent(new Event('scroll')) }) + const dragged = row(2000) + dragged.setPointerCapture = vi.fn() + view.querySelector('.playlist-track-window')!.getBoundingClientRect = () => ({ top: -40_000, height: 80_000 }) as DOMRect + const pointer = (type: string, clientY: number) => dragged.dispatchEvent(new MouseEvent(type, { bubbles: true, button: 0, clientY })) + await act(async () => pointer('pointerdown', 0)) + await act(async () => pointer('pointermove', 40)) + await act(async () => pointer('pointerup', 40)) + expect(invokeMock).toHaveBeenCalledWith('playlist_reorder', { id: 'fixture', rangeStart: 2000, insertBefore: 2002, rangeLength: 1 }) + expect(view.querySelectorAll('.playlist-track-row').length).toBeLessThan(100) + }) + it('focuses menu items and restores the trigger on Escape', async () => { function Harness() { const [open, setOpen] = useState(false) diff --git a/apps/desktop/test/mainEntry.test.tsx b/apps/desktop/test/mainEntry.test.tsx new file mode 100644 index 0000000..9843acb --- /dev/null +++ b/apps/desktop/test/mainEntry.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment jsdom +import { beforeEach, expect, it, vi } from 'vitest' + +const entry = vi.hoisted(() => ({ label: 'main', fail: false, render: vi.fn(), main: vi.fn(), importer: vi.fn() })) +vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ label: entry.label }) })) +vi.mock('react-dom/client', () => ({ createRoot: () => ({ render: entry.render }) })) +beforeEach(() => { + vi.resetModules(); vi.clearAllMocks(); entry.fail = false + vi.doMock('../src/App.tsx', () => { + entry.main() + if (entry.fail) throw new Error('missing main chunk') + return { default: () => null } + }) + vi.doMock('../src/LastFmImporter.tsx', () => { entry.importer(); return { default: () => null } }) +}) + +it.each(['main', 'lastfm-importer'])('loads only the selected %s window entry', async label => { + entry.label = label + await import('../src/main.tsx') + await vi.dynamicImportSettled() + expect(entry.main).toHaveBeenCalledTimes(label === 'main' ? 1 : 0) + expect(entry.importer).toHaveBeenCalledTimes(label === 'lastfm-importer' ? 1 : 0) + expect(entry.render.mock.calls[0][0].props.role).toBe('status') + expect(entry.render.mock.calls.at(-1)![0].props.children.type).toBeTypeOf('function') +}) + +it('replaces loading with an accessible retry when the selected chunk fails', async () => { + entry.label = 'main'; entry.fail = true + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + await import('../src/main.tsx') + await vi.dynamicImportSettled() + const result = entry.render.mock.calls.at(-1)![0] + expect(result.props.role).toBe('alert') + expect(result.props.children[1].props.children).toBe('Try again') + expect(entry.importer).not.toHaveBeenCalled() + } finally { error.mockRestore() } +}) diff --git a/docs/architecture/library.md b/docs/architecture/library.md index 2d2e687..a90dfc3 100644 --- a/docs/architecture/library.md +++ b/docs/architecture/library.md @@ -36,6 +36,12 @@ same text merges their Retune album group. Overlay edits never mutate source-file tags or Spotify metadata. +Library and playlist views share fixed-height row windowing. The complete +ordered data remains available for queues and range selection; only visible +rows plus the focused row mount in the DOM. Playlist identity remains the +upstream entry index, including duplicate URIs. Keyboard reveal and pointer +insertion operate on the complete order, independently of mounted row indices. + ## Track removal and merge decisions `Library` owns persistent local removal and merge decisions. Removing tracks diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index 2f76b83..99818af 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -57,6 +57,10 @@ reported to their caller or recovery coordinator instead of being silently discarded. Quarantine renames the evidence to a unique sibling and never writes a replacement over it during the failing load. +Startup validates and normalizes existing settings in memory without rewriting +the file. Only a missing settings file creates and atomically saves defaults; +ordinary settings mutations persist normalized values through the same owner. + `cooldowns.json` and `artist-genres.json` have separate concrete filesystem owners. Full Spotify sync receives both; content actions and importer policy receive only cooldown persistence. Their filenames and JSON formats are @@ -441,3 +445,9 @@ the unsafe boundary receives only live, nul-terminated UTF-16 path buffers. Persist only state that must survive relaunch. Keep caches reconstructible, use atomic replacement for new state files, and define migration/default behavior in tests whenever a serialized shape changes. + +Startup recovery and the initial overlay, membership, playlist, settings, and +cooldown reads run together on the blocking pool. The shell publishes their +results only after all reads succeed; application commands wait at the shell +dispatcher until initialization finishes. Existing valid settings are read without +a redundant save; missing defaults still use atomic persistence. diff --git a/docs/architecture/playback.md b/docs/architecture/playback.md index 40b88d2..cd5fb7b 100644 --- a/docs/architecture/playback.md +++ b/docs/architecture/playback.md @@ -10,6 +10,15 @@ metadata through the existing resource resolver. The importer subscribes before reading its initial playback snapshot and receives only the controlled Spotify track URI and playing flag; elapsed-time updates do not refresh the review. +The main window sends native elapsed-only changes to a per-player presentation +store subscribed by the transport. Queue, origin, metadata, playback flags, and +track changes still pass through the app reducer; the controller remains +authoritative. Simulated playback keeps its reducer timer and elapsed value. +When the document is hidden, the presentation store retains native positions +without notifying the transport and the title marquee pauses. On visibility +restoration, subscribers read the latest position. Native events, queue changes, +audio, and listening bookkeeping continue normally. + The desktop playback module is an application controller around three execution paths: built-in Spotify playback, Spotify Connect, and local-file playback. diff --git a/docs/performance/tauri-experiments/01-after-library.json b/docs/performance/tauri-experiments/01-after-library.json new file mode 100644 index 0000000..3b27c14 --- /dev/null +++ b/docs/performance/tauri-experiments/01-after-library.json @@ -0,0 +1,97 @@ +{ + "commit": "c504db2", + "tool": "React Profiler via Codex in-app Chromium; Vite development fixture; no StrictMode", + "samples": [ + { + "scenario": "library-playback", + "durationMs": 12016.400000095367, + "commits": 13, + "totalRenderMs": 9.999999523162842, + "maxRenderMs": 1.0999999046325684, + "maxFrameGapMs": 17.700000000000728, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 41, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 1.0999999046325684, + 0, + 0.39999985694885254, + 1.0999999046325684, + 0.9000000953674316, + 0.40000009536743164, + 1.0999999046325684, + 1, + 0.39999985694885254, + 0.7999999523162842, + 1, + 0.7999999523162842, + 1 + ] + }, + { + "scenario": "library-playback", + "durationMs": 12015.5, + "commits": 12, + "totalRenderMs": 9.799999475479126, + "maxRenderMs": 1, + "maxFrameGapMs": 17.700000000004366, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 41, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 0.7000000476837158, + 0.8999998569488525, + 0.8999998569488525, + 1, + 1, + 0.2999999523162842, + 0.7000000476837158, + 0.9000000953674316, + 0.8999998569488525, + 0.6999998092651367, + 0.7999999523162842, + 1 + ] + }, + { + "scenario": "library-playback", + "durationMs": 12017.700000047684, + "commits": 12, + "totalRenderMs": 9.300000190734863, + "maxRenderMs": 1.1000001430511475, + "maxFrameGapMs": 17.69999999999709, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 41, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 0.9000000953674316, + 1.1000001430511475, + 0.5999999046325684, + 0.7999999523162842, + 1, + 0.7999999523162842, + 0.6000001430511475, + 0.7999999523162842, + 0.5, + 0.7999999523162842, + 0.5, + 0.9000000953674316 + ] + } + ] +} diff --git a/docs/performance/tauri-experiments/01-after-playlist.json b/docs/performance/tauri-experiments/01-after-playlist.json new file mode 100644 index 0000000..538039d --- /dev/null +++ b/docs/performance/tauri-experiments/01-after-playlist.json @@ -0,0 +1,96 @@ +{ + "commit": "c504db2", + "tool": "React Profiler via Codex in-app Chromium; Vite development fixture; no StrictMode", + "samples": [ + { + "scenario": "playlist-playback", + "durationMs": 12018.599999904633, + "commits": 12, + "totalRenderMs": 9.900000095367432, + "maxRenderMs": 1, + "maxFrameGapMs": 50, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 4100, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 0.7999999523162842, + 0.5, + 0.9000000953674316, + 0.7999999523162842, + 0.7999999523162842, + 0.7999999523162842, + 0.9000000953674316, + 0.9000000953674316, + 0.7999999523162842, + 1, + 1, + 0.7000000476837158 + ] + }, + { + "scenario": "playlist-playback", + "durationMs": 12007.600000143051, + "commits": 12, + "totalRenderMs": 8.800000429153442, + "maxRenderMs": 1.2000000476837158, + "maxFrameGapMs": 33.30000000001746, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 4100, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 1.2000000476837158, + 0.7000000476837158, + 0.7000000476837158, + 0.8000001907348633, + 0.5, + 0.5999999046325684, + 0.39999985694885254, + 0.40000009536743164, + 0.7000000476837158, + 0.9000000953674316, + 0.7999999523162842, + 1.1000001430511475 + ] + }, + { + "scenario": "playlist-playback", + "durationMs": 12010.5, + "commits": 12, + "totalRenderMs": 9.000000715255737, + "maxRenderMs": 1.1000001430511475, + "maxFrameGapMs": 18.70000000001164, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 4100, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 0.8000001907348633, + 0.5, + 0.8000001907348633, + 0.7999999523162842, + 0.7000000476837158, + 0.5999999046325684, + 0.9000000953674316, + 0.2999999523162842, + 1.1000001430511475, + 0.7000000476837158, + 0.9000000953674316, + 0.9000000953674316 + ] + } + ] +} diff --git a/docs/performance/tauri-experiments/01-baseline-library.json b/docs/performance/tauri-experiments/01-baseline-library.json new file mode 100644 index 0000000..cf3429b --- /dev/null +++ b/docs/performance/tauri-experiments/01-baseline-library.json @@ -0,0 +1,96 @@ +{ + "commit": "cdb2648", + "tool": "React Profiler via Codex in-app Chromium; Vite development fixture; no StrictMode", + "samples": [ + { + "scenario": "library-playback", + "durationMs": 12015.900000095367, + "commits": 12, + "totalRenderMs": 303.3999996185303, + "maxRenderMs": 28.899999856948853, + "maxFrameGapMs": 17.700000000000728, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 41, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 21.299999713897705, + 21.700000286102295, + 23.300000429153442, + 25.600000381469727, + 24.09999990463257, + 26.499999523162842, + 28.59999990463257, + 28.899999856948853, + 26.699999809265137, + 27.09999990463257, + 27.200000047683716, + 22.399999856948853 + ] + }, + { + "scenario": "library-playback", + "durationMs": 12017.299999952316, + "commits": 12, + "totalRenderMs": 303.09999918937683, + "maxRenderMs": 27.99999976158142, + "maxFrameGapMs": 17.700000000000728, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 41, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 23.899999618530273, + 26.40000009536743, + 24.799999952316284, + 27.49999976158142, + 27.99999976158142, + 27.59999966621399, + 25.300000190734863, + 21.700000286102295, + 27.100000143051147, + 27.00000023841858, + 26.09999966621399, + 17.699999809265137 + ] + }, + { + "scenario": "library-playback", + "durationMs": 12018.700000047684, + "commits": 12, + "totalRenderMs": 309.0000002384186, + "maxRenderMs": 27.199999809265137, + "maxFrameGapMs": 17.700000000004366, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 41, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 24.200000286102295, + 27.199999809265137, + 25, + 26.100000381469727, + 25.399999856948853, + 24.40000009536743, + 25.299999952316284, + 26.5, + 26.90000009536743, + 26.40000009536743, + 25.59999990463257, + 25.99999976158142 + ] + } + ] +} diff --git a/docs/performance/tauri-experiments/01-baseline-playlist.json b/docs/performance/tauri-experiments/01-baseline-playlist.json new file mode 100644 index 0000000..733f83c --- /dev/null +++ b/docs/performance/tauri-experiments/01-baseline-playlist.json @@ -0,0 +1,206 @@ +{ + "commit": "8868b59", + "tool": "React Profiler via Codex in-app Chromium; Vite development fixture; no StrictMode", + "samples": [ + { + "scenario": "playlist-playback", + "durationMs": 12016, + "commits": 24, + "totalRenderMs": 3787.6999979019165, + "maxRenderMs": 223.60000109672546, + "maxFrameGapMs": 450.60000000000036, + "longTasksMs": [ + 462, + 242, + 205, + 306, + 181, + 304, + 183, + 303, + 182, + 292, + 182, + 292, + 183, + 304, + 180, + 307, + 184, + 305, + 184, + 307, + 180, + 193, + 272 + ], + "ipcDuringTicks": 0, + "rows": 4100, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 181.099999666214, + 104.09999966621399, + 159.69999957084656, + 126.40000128746033, + 223.60000109672546, + 104.20000076293945, + 219.70000052452087, + 101.29999828338623, + 220.99999833106995, + 105.29999876022339, + 209.09999990463257, + 104.09999775886536, + 210.30000233650208, + 104.70000076293945, + 222.19999933242798, + 103.4999988079071, + 223.09999990463257, + 106.19999933242798, + 221.00000071525574, + 105.09999990463257, + 223.20000100135803, + 104.20000052452087, + 109.59999942779541, + 195.00000023841858 + ] + }, + { + "scenario": "playlist-playback", + "durationMs": 12016.299999952316, + "commits": 24, + "totalRenderMs": 3932.899995803833, + "maxRenderMs": 227.1999990940094, + "maxFrameGapMs": 317.3999999999942, + "longTasksMs": [ + 300, + 184, + 301, + 189, + 301, + 185, + 299, + 187, + 299, + 186, + 298, + 210, + 305, + 183, + 323, + 196, + 298, + 185, + 300, + 185, + 299, + 186, + 303, + 185 + ], + "ipcDuringTicks": 0, + "rows": 4100, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 217.90000176429749, + 103.2999997138977, + 218.60000014305115, + 106.69999861717224, + 216.5, + 106.20000100135803, + 218.4999988079071, + 108.30000066757202, + 217.89999914169312, + 107.1999990940094, + 217.7000014781952, + 129.99999928474426, + 223.89999842643738, + 104.19999957084656, + 227.1999990940094, + 106.10000014305115, + 216.90000009536743, + 104.7000002861023, + 220.6999990940094, + 106.60000038146973, + 219.89999890327454, + 105.80000042915344, + 221.99999928474426, + 106.10000038146973 + ] + }, + { + "scenario": "playlist-playback", + "durationMs": 12017, + "commits": 24, + "totalRenderMs": 3938.100007534027, + "maxRenderMs": 230.0000011920929, + "maxFrameGapMs": 301, + "longTasksMs": [ + 303, + 183, + 303, + 182, + 302, + 181, + 301, + 181, + 301, + 183, + 303, + 184, + 314, + 207, + 310, + 195, + 302, + 184, + 301, + 191, + 305, + 183, + 303, + 180 + ], + "ipcDuringTicks": 0, + "rows": 4100, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 221.50000143051147, + 103.5, + 222.99999856948853, + 101.4999988079071, + 219.29999947547913, + 103.79999995231628, + 219.8999993801117, + 105.3000009059906, + 219.20000004768372, + 105.70000195503235, + 221.80000138282776, + 106.19999980926514, + 230.0000011920929, + 130.79999995231628, + 225.60000109672546, + 111.80000042915344, + 219.00000143051147, + 104.79999852180481, + 210.70000004768372, + 105.80000019073486, + 221.10000109672546, + 103.99999976158142, + 220.50000286102295, + 103.29999923706055 + ] + } + ] +} diff --git a/docs/performance/tauri-experiments/02-after-interactions.json b/docs/performance/tauri-experiments/02-after-interactions.json new file mode 100644 index 0000000..54f0d94 --- /dev/null +++ b/docs/performance/tauri-experiments/02-after-interactions.json @@ -0,0 +1,210 @@ +{ + "commit": "04ad86a", + "tool": "React Profiler and two animation frames per action, Codex in-app Chromium", + "samples": [ + { + "viewport": [ + 1280, + 720 + ], + "actions": [ + { + "name": "open", + "durationMs": 32, + "renderMs": 16.300000190734863, + "commits": 4, + "rows": 47, + "focusedRow": null, + "longTasksMs": [], + "maxFrameGapMs": 16.699999999999932 + }, + { + "name": "select", + "durationMs": 31.799999952316284, + "renderMs": 9.5, + "commits": 2, + "rows": 47, + "focusedRow": null, + "longTasksMs": [], + "maxFrameGapMs": 16.700000000000045 + }, + { + "name": "end", + "durationMs": 33.299999952316284, + "renderMs": 11.399999856948853, + "commits": 2, + "rows": 47, + "focusedRow": "Track 99 by Artist 99", + "longTasksMs": [], + "maxFrameGapMs": 16.700000000000045 + }, + { + "name": "shift-home", + "durationMs": 33.30000019073486, + "renderMs": 5.5, + "commits": 2, + "rows": 47, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [], + "maxFrameGapMs": 16.700000000000045 + }, + { + "name": "sort", + "durationMs": 33.39999985694885, + "renderMs": 18.09999990463257, + "commits": 1, + "rows": 47, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [], + "maxFrameGapMs": 16.699999999999932 + }, + { + "name": "scroll-middle", + "durationMs": 33.19999980926514, + "renderMs": 5.200000047683716, + "commits": 1, + "rows": 48, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [], + "maxFrameGapMs": 16.700000000000045 + } + ] + }, + { + "viewport": [ + 1280, + 720 + ], + "actions": [ + { + "name": "open", + "durationMs": 32, + "renderMs": 16.200000286102295, + "commits": 4, + "rows": 47, + "focusedRow": null, + "longTasksMs": [], + "maxFrameGapMs": 16.700000000000728 + }, + { + "name": "select", + "durationMs": 32, + "renderMs": 10.100000143051147, + "commits": 2, + "rows": 47, + "focusedRow": null, + "longTasksMs": [], + "maxFrameGapMs": 17.400000000001455 + }, + { + "name": "end", + "durationMs": 33.40000009536743, + "renderMs": 12.699999809265137, + "commits": 2, + "rows": 47, + "focusedRow": "Track 99 by Artist 99", + "longTasksMs": [], + "maxFrameGapMs": 16.700000000000728 + }, + { + "name": "shift-home", + "durationMs": 33.40000009536743, + "renderMs": 12.599999904632568, + "commits": 2, + "rows": 47, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [], + "maxFrameGapMs": 16.799999999999272 + }, + { + "name": "sort", + "durationMs": 33.299999952316284, + "renderMs": 15.599999904632568, + "commits": 1, + "rows": 47, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [], + "maxFrameGapMs": 16.600000000002183 + }, + { + "name": "scroll-middle", + "durationMs": 33.299999952316284, + "renderMs": 4.500000238418579, + "commits": 1, + "rows": 48, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [], + "maxFrameGapMs": 16.799999999999272 + } + ] + }, + { + "viewport": [ + 1280, + 720 + ], + "actions": [ + { + "name": "open", + "durationMs": 50.40000009536743, + "renderMs": 28.300000190734863, + "commits": 4, + "rows": 47, + "focusedRow": null, + "longTasksMs": [], + "maxFrameGapMs": 33.299999999995634 + }, + { + "name": "select", + "durationMs": 31.200000047683716, + "renderMs": 11.40000033378601, + "commits": 2, + "rows": 47, + "focusedRow": null, + "longTasksMs": [], + "maxFrameGapMs": 16.700000000004366 + }, + { + "name": "end", + "durationMs": 33.40000009536743, + "renderMs": 13.299999952316284, + "commits": 2, + "rows": 47, + "focusedRow": "Track 99 by Artist 99", + "longTasksMs": [], + "maxFrameGapMs": 16.80000000000291 + }, + { + "name": "shift-home", + "durationMs": 33.299999952316284, + "renderMs": 12.799999952316284, + "commits": 2, + "rows": 47, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [], + "maxFrameGapMs": 16.69999999999709 + }, + { + "name": "sort", + "durationMs": 33.30000019073486, + "renderMs": 17.299999952316284, + "commits": 1, + "rows": 47, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [], + "maxFrameGapMs": 16.69999999999709 + }, + { + "name": "scroll-middle", + "durationMs": 33.299999952316284, + "renderMs": 4.299999952316284, + "commits": 1, + "rows": 48, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [], + "maxFrameGapMs": 18.200000000004366 + } + ] + } + ] +} diff --git a/docs/performance/tauri-experiments/02-baseline-interactions.json b/docs/performance/tauri-experiments/02-baseline-interactions.json new file mode 100644 index 0000000..8fad44f --- /dev/null +++ b/docs/performance/tauri-experiments/02-baseline-interactions.json @@ -0,0 +1,249 @@ +{ + "commit": "22342c7", + "tool": "React Profiler and two animation frames per action, Codex in-app Chromium", + "samples": [ + { + "viewport": [ + 1280, + 720 + ], + "actions": [ + { + "name": "open", + "durationMs": 730.2999999523163, + "renderMs": 387.6999981403351, + "commits": 2, + "rows": 4100, + "focusedRow": null, + "longTasksMs": [ + 430, + 287 + ], + "maxFrameGapMs": 700.3 + }, + { + "name": "select", + "durationMs": 333.89999985694885, + "renderMs": 202.29999947547913, + "commits": 1, + "rows": 4100, + "focusedRow": null, + "longTasksMs": [ + 319 + ], + "maxFrameGapMs": 333.5 + }, + { + "name": "end", + "durationMs": 416.39999985694885, + "renderMs": 213.8000009059906, + "commits": 2, + "rows": 4100, + "focusedRow": "Track 99 by Artist 99", + "longTasksMs": [ + 208, + 208 + ], + "maxFrameGapMs": 200.10000000000014 + }, + { + "name": "shift-home", + "durationMs": 590.2999999523163, + "renderMs": 312.6000008583069, + "commits": 2, + "rows": 4100, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [ + 279, + 310 + ], + "maxFrameGapMs": 316.8000000000002 + }, + { + "name": "sort", + "durationMs": 582.2999999523163, + "renderMs": 122.49999928474426, + "commits": 1, + "rows": 4100, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [ + 575 + ], + "maxFrameGapMs": 566.7999999999997 + }, + { + "name": "scroll-middle", + "durationMs": 31.200000047683716, + "renderMs": 0, + "commits": 0, + "rows": 4100, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [], + "maxFrameGapMs": 16.699999999999818 + } + ] + }, + { + "viewport": [ + 1280, + 720 + ], + "actions": [ + { + "name": "open", + "durationMs": 769.7000000476837, + "renderMs": 401.30000042915344, + "commits": 2, + "rows": 4100, + "focusedRow": null, + "longTasksMs": [ + 443, + 312 + ], + "maxFrameGapMs": 750.3000000000029 + }, + { + "name": "select", + "durationMs": 370.30000019073486, + "renderMs": 216.60000014305115, + "commits": 1, + "rows": 4100, + "focusedRow": null, + "longTasksMs": [ + 364 + ], + "maxFrameGapMs": 366.79999999999563 + }, + { + "name": "end", + "durationMs": 437.60000014305115, + "renderMs": 221.9000005722046, + "commits": 2, + "rows": 4100, + "focusedRow": "Track 99 by Artist 99", + "longTasksMs": [ + 221, + 216 + ], + "maxFrameGapMs": 216.6999999999971 + }, + { + "name": "shift-home", + "durationMs": 628, + "renderMs": 335.3000009059906, + "commits": 2, + "rows": 4100, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [ + 313, + 314 + ], + "maxFrameGapMs": 317.6999999999971 + }, + { + "name": "sort", + "durationMs": 536.3999998569489, + "renderMs": 125.99999928474426, + "commits": 1, + "rows": 4100, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [ + 536 + ], + "maxFrameGapMs": 533.5999999999985 + }, + { + "name": "scroll-middle", + "durationMs": 30.90000009536743, + "renderMs": 0, + "commits": 0, + "rows": 4100, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [], + "maxFrameGapMs": 18.5 + } + ] + }, + { + "viewport": [ + 1280, + 720 + ], + "actions": [ + { + "name": "open", + "durationMs": 797.4000000953674, + "renderMs": 421.2000005245209, + "commits": 2, + "rows": 4100, + "focusedRow": null, + "longTasksMs": [ + 463, + 319 + ], + "maxFrameGapMs": 767 + }, + { + "name": "select", + "durationMs": 350.30000019073486, + "renderMs": 203.70000052452087, + "commits": 1, + "rows": 4100, + "focusedRow": null, + "longTasksMs": [ + 345 + ], + "maxFrameGapMs": 350.1999999999971 + }, + { + "name": "end", + "durationMs": 420.7000000476837, + "renderMs": 216.00000095367432, + "commits": 2, + "rows": 4100, + "focusedRow": "Track 99 by Artist 99", + "longTasksMs": [ + 212, + 208 + ], + "maxFrameGapMs": 216.79999999999563 + }, + { + "name": "shift-home", + "durationMs": 620.5, + "renderMs": 329.4000036716461, + "commits": 2, + "rows": 4100, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [ + 309, + 309 + ], + "maxFrameGapMs": 316.79999999999563 + }, + { + "name": "sort", + "durationMs": 525.2999999523163, + "renderMs": 122.50000023841858, + "commits": 1, + "rows": 4100, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [ + 523 + ], + "maxFrameGapMs": 516.9000000000015 + }, + { + "name": "scroll-middle", + "durationMs": 33, + "renderMs": 0, + "commits": 0, + "rows": 4100, + "focusedRow": "Track 0 by Artist 0", + "longTasksMs": [], + "maxFrameGapMs": 16.80000000000291 + } + ] + } + ] +} diff --git a/docs/performance/tauri-experiments/03-after-hidden.json b/docs/performance/tauri-experiments/03-after-hidden.json new file mode 100644 index 0000000..99abff9 --- /dev/null +++ b/docs/performance/tauri-experiments/03-after-hidden.json @@ -0,0 +1,69 @@ +{ + "commit": "ca9a4ee", + "tool": "React Profiler; simulated document visibility in Chromium, not native hiding", + "samples": [ + { + "scenario": "library-playback", + "durationMs": 12009.100000143051, + "commits": 0, + "totalRenderMs": 0, + "maxRenderMs": 0, + "maxFrameGapMs": 17.700000000000728, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 41, + "elapsed": "0", + "viewport": [ + 1280, + 720 + ], + "samples": [], + "simulatedVisibility": "hidden", + "animationStates": [ + "paused" + ] + }, + { + "scenario": "library-playback", + "durationMs": 12018.200000047684, + "commits": 0, + "totalRenderMs": 0, + "maxRenderMs": 0, + "maxFrameGapMs": 18.700000000004366, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 41, + "elapsed": "0", + "viewport": [ + 1280, + 720 + ], + "samples": [], + "simulatedVisibility": "hidden", + "animationStates": [ + "paused" + ] + }, + { + "scenario": "library-playback", + "durationMs": 12019.099999904633, + "commits": 0, + "totalRenderMs": 0, + "maxRenderMs": 0, + "maxFrameGapMs": 18.69999999999709, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 41, + "elapsed": "0", + "viewport": [ + 1280, + 720 + ], + "samples": [], + "simulatedVisibility": "hidden", + "animationStates": [ + "paused" + ] + } + ] +} diff --git a/docs/performance/tauri-experiments/03-baseline-hidden.json b/docs/performance/tauri-experiments/03-baseline-hidden.json new file mode 100644 index 0000000..d80430a --- /dev/null +++ b/docs/performance/tauri-experiments/03-baseline-hidden.json @@ -0,0 +1,109 @@ +{ + "commit": "71467f3", + "tool": "React Profiler; simulated document visibility in Chromium, not native hiding", + "samples": [ + { + "scenario": "library-playback", + "durationMs": 12015.399999856949, + "commits": 13, + "totalRenderMs": 7.6999993324279785, + "maxRenderMs": 0.7999999523162842, + "maxFrameGapMs": 17.700000000000728, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 41, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 0.6999998092651367, + 0, + 0.6999998092651367, + 0.3000001907348633, + 0.6999998092651367, + 0.7000000476837158, + 0.7000000476837158, + 0.6000001430511475, + 0.5999999046325684, + 0.6999998092651367, + 0.5999999046325684, + 0.5999999046325684, + 0.7999999523162842 + ], + "simulatedVisibility": "hidden", + "animationStates": [ + "running" + ] + }, + { + "scenario": "library-playback", + "durationMs": 12012.400000095367, + "commits": 12, + "totalRenderMs": 8.199999570846558, + "maxRenderMs": 0.8000001907348633, + "maxFrameGapMs": 17.700000000004366, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 41, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 0.7999999523162842, + 0.8000001907348633, + 0.8000001907348633, + 0.39999985694885254, + 0.7999999523162842, + 0.7000000476837158, + 0.5999999046325684, + 0.5999999046325684, + 0.6999998092651367, + 0.5999999046325684, + 0.7000000476837158, + 0.6999998092651367 + ], + "simulatedVisibility": "hidden", + "animationStates": [ + "running" + ] + }, + { + "scenario": "library-playback", + "durationMs": 12012.100000143051, + "commits": 12, + "totalRenderMs": 9, + "maxRenderMs": 0.8000001907348633, + "maxFrameGapMs": 17.70000000001164, + "longTasksMs": [], + "ipcDuringTicks": 0, + "rows": 41, + "elapsed": "12", + "viewport": [ + 1280, + 720 + ], + "samples": [ + 0.7999999523162842, + 0.7999999523162842, + 0.7000000476837158, + 0.7999999523162842, + 0.7999999523162842, + 0.7999999523162842, + 0.6000001430511475, + 0.8000001907348633, + 0.7000000476837158, + 0.6999998092651367, + 0.7000000476837158, + 0.7999999523162842 + ], + "simulatedVisibility": "hidden", + "animationStates": [ + "running" + ] + } + ] +} diff --git a/docs/performance/tauri-experiments/04a-after-settings.json b/docs/performance/tauri-experiments/04a-after-settings.json new file mode 100644 index 0000000..00a95da --- /dev/null +++ b/docs/performance/tauri-experiments/04a-after-settings.json @@ -0,0 +1,11 @@ +{ + "commit": "eb7fae6", + "tool": "cargo test --release -p retune-desktop startup_settings -- --include-ignored --nocapture", + "conditions": "macOS arm64; temporary directories on local filesystem; first missing-file load then 100 warm existing-file loads per sample; real atomic writes/fsync; no native window", + "samples": [ + {"existingTotalMs":5.3585,"iterations":100,"missingMs":11.578375,"sample":1,"scenario":"startup-settings"}, + {"existingTotalMs":4.437209,"iterations":100,"missingMs":4.900042,"sample":2,"scenario":"startup-settings"}, + {"existingTotalMs":4.515291,"iterations":100,"missingMs":5.338583,"sample":3,"scenario":"startup-settings"} + ], + "checks": {"startup_settings_create_missing_defaults_without_rewriting_existing_or_invalid_files":"PASS"} +} diff --git a/docs/performance/tauri-experiments/04a-baseline-settings.json b/docs/performance/tauri-experiments/04a-baseline-settings.json new file mode 100644 index 0000000..4aa9e1a --- /dev/null +++ b/docs/performance/tauri-experiments/04a-baseline-settings.json @@ -0,0 +1,10 @@ +{ + "commit": "70c4a94", + "tool": "cargo test --release -p retune-desktop startup_settings_performance -- --ignored --nocapture", + "conditions": "macOS arm64; temporary directories on local filesystem; first missing-file load then 100 warm existing-file loads per sample; real atomic writes/fsync; no native window", + "samples": [ + {"existingTotalMs":485.06629200000003,"iterations":100,"missingMs":10.042875,"sample":1,"scenario":"startup-settings"}, + {"existingTotalMs":467.360333,"iterations":100,"missingMs":4.71525,"sample":2,"scenario":"startup-settings"}, + {"existingTotalMs":481.19408400000003,"iterations":100,"missingMs":4.605584,"sample":3,"scenario":"startup-settings"} + ] +} diff --git a/docs/performance/tauri-experiments/04b-after-bundle.json b/docs/performance/tauri-experiments/04b-after-bundle.json new file mode 100644 index 0000000..11cb3e4 --- /dev/null +++ b/docs/performance/tauri-experiments/04b-after-bundle.json @@ -0,0 +1,110 @@ +{ + "commit": "48456a6", + "tool": "npm run build -- --manifest; Python stdlib gzip/SHA256", + "manifest": { + "_lastfmImportState-C3C4uACG.css": { + "file": "assets/lastfmImportState-C3C4uACG.css", + "src": "_lastfmImportState-C3C4uACG.css" + }, + "_lastfmImportState-Dd4bgDdE.js": { + "file": "assets/lastfmImportState-Dd4bgDdE.js", + "name": "lastfmImportState", + "imports": [ + "index.html" + ], + "css": [ + "assets/lastfmImportState-C3C4uACG.css" + ] + }, + "index.html": { + "file": "assets/index-DrbSfPCQ.js", + "name": "index", + "src": "index.html", + "isEntry": true, + "dynamicImports": [ + "src/App.tsx", + "src/LastFmImporter.tsx" + ], + "css": [ + "assets/index-Dxds_cH_.css" + ] + }, + "src/App.tsx": { + "file": "assets/App-DhC3qZxu.js", + "name": "App", + "src": "src/App.tsx", + "isDynamicEntry": true, + "imports": [ + "index.html", + "_lastfmImportState-Dd4bgDdE.js" + ] + }, + "src/LastFmImporter.tsx": { + "file": "assets/LastFmImporter-D_S9Mc6S.js", + "name": "LastFmImporter", + "src": "src/LastFmImporter.tsx", + "isDynamicEntry": true, + "imports": [ + "index.html", + "_lastfmImportState-Dd4bgDdE.js" + ], + "css": [ + "assets/LastFmImporter-DryE5JpI.css" + ] + } + }, + "files": [ + { + "path": "assets/App-DhC3qZxu.js", + "bytes": 74107, + "gzipBytes": 21853, + "sha256": "9ba19a268abc0bac4a31a649126b243ccfad0a3400201ecbe6945c1e1b9ddcf7" + }, + { + "path": "assets/LastFmImporter-D_S9Mc6S.js", + "bytes": 74064, + "gzipBytes": 21713, + "sha256": "a2c81988ff6a253b88c565adcae6a306977be26a2fd5de674f816b332b700ed7" + }, + { + "path": "assets/LastFmImporter-DryE5JpI.css", + "bytes": 27890, + "gzipBytes": 5269, + "sha256": "a92d5493e629794719453bbd9f0355b35a19247a26a2af5755ad396bcb60299b" + }, + { + "path": "assets/index-DrbSfPCQ.js", + "bytes": 208815, + "gzipBytes": 63938, + "sha256": "029df3118c43a15a83db3b2d7d33ecc716fc858cd327c2048e9bef3ae75646da" + }, + { + "path": "assets/index-Dxds_cH_.css", + "bytes": 43127, + "gzipBytes": 8390, + "sha256": "6f14dea48475071526aff9283f33c722a58232929d0c1f3a343b26bd466f33fb" + }, + { + "path": "assets/lastfmImportState-C3C4uACG.css", + "bytes": 6827, + "gzipBytes": 1804, + "sha256": "b46b95a28972be1e5e3af7569b995ca35cf20ae8f85332881cc75691954a27a2" + }, + { + "path": "assets/lastfmImportState-Dd4bgDdE.js", + "bytes": 94196, + "gzipBytes": 26882, + "sha256": "575ed34ac03d8d5e2e72adcb0fc422a5b0c21a8e149ab70cf61594fa1c406607" + } + ], + "routes": { + "main": { + "jsBytes": 377118, + "gzipJsBytes": 112673 + }, + "importer": { + "jsBytes": 377075, + "gzipJsBytes": 112533 + } + } +} diff --git a/docs/performance/tauri-experiments/04b-after-startup.json b/docs/performance/tauri-experiments/04b-after-startup.json new file mode 100644 index 0000000..a01cddd --- /dev/null +++ b/docs/performance/tauri-experiments/04b-after-startup.json @@ -0,0 +1,277 @@ +{ + "commit": "48456a6", + "tool": "Vite production fixture, Chromium Performance API and DOM readiness + two animation frames", + "conditions": "same in-app Chromium session,1280x720,warm HTTP cache,full page reload each sample,no added throttling,real main.tsx with deterministic native mock", + "samples": [ + { + "window": "main", + "readyMs": 31, + "paintedMs": 48.90000009536743, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-Ds67CJZD.js", + "durationMs": 3.200000047683716, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "startup-CQsGNWnw.css", + "durationMs": 2.799999952316284, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "App-BE13pB-k.js", + "durationMs": 1.5, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-CN6GnfYQ.css", + "durationMs": 0.7999999523162842, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-afqaLREX.js", + "durationMs": 1.0999999046325684, + "transferSize": 300, + "decodedBodySize": 0 + } + ] + }, + { + "window": "main", + "readyMs": 31, + "paintedMs": 54.200000047683716, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-CQsGNWnw.css", + "durationMs": 2.9000000953674316, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "startup-Ds67CJZD.js", + "durationMs": 3.700000047683716, + "transferSize": 300, + "decodedBodySize": 207818 + }, + { + "name": "App-BE13pB-k.js", + "durationMs": 2.0999999046325684, + "transferSize": 300, + "decodedBodySize": 65753 + }, + { + "name": "lastfmImportState-CN6GnfYQ.css", + "durationMs": 1.3000001907348633, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-afqaLREX.js", + "durationMs": 3.3000001907348633, + "transferSize": 300, + "decodedBodySize": 85186 + } + ] + }, + { + "window": "main", + "readyMs": 30.799999952316284, + "paintedMs": 42.700000047683716, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-CQsGNWnw.css", + "durationMs": 3.5999999046325684, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "startup-Ds67CJZD.js", + "durationMs": 3.200000047683716, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "App-BE13pB-k.js", + "durationMs": 1.1000001430511475, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-afqaLREX.js", + "durationMs": 1.7000000476837158, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-CN6GnfYQ.css", + "durationMs": 1.2999999523162842, + "transferSize": 300, + "decodedBodySize": 0 + } + ] + }, + { + "window": "lastfm-importer", + "readyMs": 19.299999952316284, + "paintedMs": 52.5, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-Ds67CJZD.js", + "durationMs": 4.3999998569488525, + "transferSize": 300, + "decodedBodySize": 207818 + }, + { + "name": "startup-CQsGNWnw.css", + "durationMs": 3.3999998569488525, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "LastFmImporter-DfcuTYPD.js", + "durationMs": 3.3000001907348633, + "transferSize": 300, + "decodedBodySize": 72012 + }, + { + "name": "lastfmImportState-CN6GnfYQ.css", + "durationMs": 1.2000000476837158, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "LastFmImporter-XF_fEw_J.css", + "durationMs": 1.8999998569488525, + "transferSize": 300, + "decodedBodySize": 27486 + }, + { + "name": "lastfmImportState-afqaLREX.js", + "durationMs": 1.5999999046325684, + "transferSize": 300, + "decodedBodySize": 85186 + } + ] + }, + { + "window": "lastfm-importer", + "readyMs": 17.199999809265137, + "paintedMs": 49, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-CQsGNWnw.css", + "durationMs": 3.200000047683716, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "startup-Ds67CJZD.js", + "durationMs": 3.5999999046325684, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "LastFmImporter-DfcuTYPD.js", + "durationMs": 1, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-afqaLREX.js", + "durationMs": 1.4000000953674316, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-CN6GnfYQ.css", + "durationMs": 1.7000000476837158, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "LastFmImporter-XF_fEw_J.css", + "durationMs": 2.8999998569488525, + "transferSize": 300, + "decodedBodySize": 0 + } + ] + }, + { + "window": "lastfm-importer", + "readyMs": 17.199999809265137, + "paintedMs": 34.299999952316284, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-Ds67CJZD.js", + "durationMs": 3.6000001430511475, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "startup-CQsGNWnw.css", + "durationMs": 3.200000047683716, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-CN6GnfYQ.css", + "durationMs": 1.5, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "LastFmImporter-DfcuTYPD.js", + "durationMs": 1.2000000476837158, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-afqaLREX.js", + "durationMs": 3.200000047683716, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "LastFmImporter-XF_fEw_J.css", + "durationMs": 1.7000000476837158, + "transferSize": 300, + "decodedBodySize": 0 + } + ] + } + ] +} diff --git a/docs/performance/tauri-experiments/04b-baseline-bundle.json b/docs/performance/tauri-experiments/04b-baseline-bundle.json new file mode 100644 index 0000000..975ff2f --- /dev/null +++ b/docs/performance/tauri-experiments/04b-baseline-bundle.json @@ -0,0 +1,29 @@ +{ + "commit": "67f09f6", + "tool": "npm run build -- --manifest; Python stdlib gzip/SHA256", + "manifest": { + "index.html": { + "file": "assets/index-BvQbcGg-.js", + "name": "index", + "src": "index.html", + "isEntry": true, + "css": [ + "assets/index-CCfdujPN.css" + ] + } + }, + "files": [ + { + "path": "assets/index-BvQbcGg-.js", + "bytes": 446859, + "gzipBytes": 130008, + "sha256": "32fc5b6a39e307cf597aa47b6a1f22912bc4206d955d231fb8ae0651f8b3c049" + }, + { + "path": "assets/index-CCfdujPN.css", + "bytes": 77842, + "gzipBytes": 13842, + "sha256": "92a17d229063bfbf7cc90ff97ab4d0e5bf91ab797a0b50bdbb533d987d2e8683" + } + ] +} diff --git a/docs/performance/tauri-experiments/04b-baseline-startup.json b/docs/performance/tauri-experiments/04b-baseline-startup.json new file mode 100644 index 0000000..153955e --- /dev/null +++ b/docs/performance/tauri-experiments/04b-baseline-startup.json @@ -0,0 +1,151 @@ +{ + "commit": "67f09f6", + "tool": "Vite production fixture, Chromium Performance API and DOM readiness + two animation frames", + "conditions": "same in-app Chromium session,1280x720,warm HTTP cache,full page reload each sample,no added throttling,real main.tsx with deterministic native mock", + "samples": [ + { + "window": "main", + "readyMs": 33.299999952316284, + "paintedMs": 51.299999952316284, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-Daf8KNiW.css", + "durationMs": 3.700000047683716, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "startup-jyL-p--S.js", + "durationMs": 4.299999952316284, + "transferSize": 300, + "decodedBodySize": 0 + } + ] + }, + { + "window": "main", + "readyMs": 30.799999952316284, + "paintedMs": 51.09999990463257, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-Daf8KNiW.css", + "durationMs": 3.299999952316284, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "startup-jyL-p--S.js", + "durationMs": 3.6999998092651367, + "transferSize": 300, + "decodedBodySize": 0 + } + ] + }, + { + "window": "main", + "readyMs": 29.700000047683716, + "paintedMs": 36.200000047683716, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-jyL-p--S.js", + "durationMs": 3.6000001430511475, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "startup-Daf8KNiW.css", + "durationMs": 3.0999999046325684, + "transferSize": 300, + "decodedBodySize": 0 + } + ] + }, + { + "window": "lastfm-importer", + "readyMs": 18.40000009536743, + "paintedMs": 44.59999990463257, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-jyL-p--S.js", + "durationMs": 3.5, + "transferSize": 300, + "decodedBodySize": 426606 + }, + { + "name": "startup-Daf8KNiW.css", + "durationMs": 3.5, + "transferSize": 300, + "decodedBodySize": 0 + } + ] + }, + { + "window": "lastfm-importer", + "readyMs": 16.600000143051147, + "paintedMs": 34.60000014305115, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-jyL-p--S.js", + "durationMs": 3.5, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "startup-Daf8KNiW.css", + "durationMs": 3, + "transferSize": 300, + "decodedBodySize": 0 + } + ] + }, + { + "window": "lastfm-importer", + "readyMs": 14.899999856948853, + "paintedMs": 44.299999952316284, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-jyL-p--S.js", + "durationMs": 3.799999952316284, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "startup-Daf8KNiW.css", + "durationMs": 3.3999998569488525, + "transferSize": 300, + "decodedBodySize": 0 + } + ] + } + ] +} diff --git a/docs/performance/tauri-experiments/04b-rejected-conditional-loader.json b/docs/performance/tauri-experiments/04b-rejected-conditional-loader.json new file mode 100644 index 0000000..02dbe0a --- /dev/null +++ b/docs/performance/tauri-experiments/04b-rejected-conditional-loader.json @@ -0,0 +1,150 @@ +{ + "commit": "280e840", + "reason": "Bundled preload helper selected App dependencies even in importer branch; importer CSS was not requested. Functional/style regression; not a valid final candidate.", + "samples": [ + { + "window": "lastfm-importer", + "readyMs": 17.200000047683716, + "paintedMs": 41.5, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-DyC0tVoY.js", + "durationMs": 3.4000000953674316, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "startup-CQsGNWnw.css", + "durationMs": 2.9000000953674316, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-CN6GnfYQ.css", + "durationMs": 1.7999999523162842, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "App-DS2gWa9V.js", + "durationMs": 1.0999999046325684, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-9mit3wQl.js", + "durationMs": 1.4000000953674316, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "LastFmImporter-BpAVFAra.js", + "durationMs": 1.2999999523162842, + "transferSize": 300, + "decodedBodySize": 0 + } + ] + }, + { + "window": "lastfm-importer", + "readyMs": 16.700000047683716, + "paintedMs": 36.80000019073486, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-CQsGNWnw.css", + "durationMs": 3.5, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "startup-DyC0tVoY.js", + "durationMs": 3.200000047683716, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-9mit3wQl.js", + "durationMs": 1.0999999046325684, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "App-DS2gWa9V.js", + "durationMs": 1.5999999046325684, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-CN6GnfYQ.css", + "durationMs": 1.2000000476837158, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "LastFmImporter-BpAVFAra.js", + "durationMs": 1.8999998569488525, + "transferSize": 300, + "decodedBodySize": 0 + } + ] + }, + { + "window": "lastfm-importer", + "readyMs": 15.599999904632568, + "paintedMs": 33.59999990463257, + "viewport": [ + 1280, + 720 + ], + "longTasksMs": [], + "resources": [ + { + "name": "startup-CQsGNWnw.css", + "durationMs": 3, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "startup-DyC0tVoY.js", + "durationMs": 3.4000000953674316, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-CN6GnfYQ.css", + "durationMs": 1.2000000476837158, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "App-DS2gWa9V.js", + "durationMs": 0.9000000953674316, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "lastfmImportState-9mit3wQl.js", + "durationMs": 1.4000000953674316, + "transferSize": 300, + "decodedBodySize": 0 + }, + { + "name": "LastFmImporter-BpAVFAra.js", + "durationMs": 1.9000000953674316, + "transferSize": 300, + "decodedBodySize": 0 + } + ] + } + ] +} diff --git a/docs/performance/tauri-experiments/04c-after-files.json b/docs/performance/tauri-experiments/04c-after-files.json new file mode 100644 index 0000000..5ed3fa2 --- /dev/null +++ b/docs/performance/tauri-experiments/04c-after-files.json @@ -0,0 +1,21 @@ +{ + "source": "78ad327", + "conditions": "Same release Rust benchmark and generated 4000-track fixture; no UI/native paint measured. dispatchMs measures returning the pending load; readyMs includes awaited completion. This cannot resolve first-paint timing.", + "samples": [ + { + "sample": 1, + "dispatchMs": 0.000291, + "readyMs": 10.7255 + }, + { + "sample": 2, + "dispatchMs": 0.012375, + "readyMs": 9.463167 + }, + { + "sample": 3, + "dispatchMs": 0.000208, + "readyMs": 8.349041 + } + ] +} diff --git a/docs/performance/tauri-experiments/04c-baseline-files.json b/docs/performance/tauri-experiments/04c-baseline-files.json new file mode 100644 index 0000000..f890b68 --- /dev/null +++ b/docs/performance/tauri-experiments/04c-baseline-files.json @@ -0,0 +1,21 @@ +{ + "source": "78c76f9", + "conditions": "Release Rust, warm local filesystem, copied generated 4000-track library/settings, no UI/native paint measured", + "samples": [ + { + "sample": 1, + "dispatchMs": 14.910417, + "readyMs": 14.910459 + }, + { + "sample": 2, + "dispatchMs": 10.448042, + "readyMs": 10.448125 + }, + { + "sample": 3, + "dispatchMs": 9.535333, + "readyMs": 9.535375 + } + ] +} diff --git a/docs/performance/tauri-experiments/05-after-refresh.json b/docs/performance/tauri-experiments/05-after-refresh.json new file mode 100644 index 0000000..30516a1 --- /dev/null +++ b/docs/performance/tauri-experiments/05-after-refresh.json @@ -0,0 +1,45 @@ +{ + "source": "fea5335", + "conditions": "Same fixture and Chromium session as baseline; three samples with no build/test commands running", + "samples": [ + { + "durationMs": 169.89999985694885, + "queueCalls": 13, + "queueBytes": 4283276, + "renderMs": 5.799999952316284, + "commits": 2, + "queueRows": 13, + "selectionLabel": " Select all 8,000 resultsCombine selected (0)", + "viewport": [ + 1280, + 720 + ] + }, + { + "durationMs": 173.09999990463257, + "queueCalls": 13, + "queueBytes": 4283276, + "renderMs": 5.900000095367432, + "commits": 2, + "queueRows": 13, + "selectionLabel": " Select all 8,000 resultsCombine selected (0)", + "viewport": [ + 1280, + 720 + ] + }, + { + "durationMs": 174.20000004768372, + "queueCalls": 13, + "queueBytes": 4283276, + "renderMs": 7, + "commits": 1, + "queueRows": 13, + "selectionLabel": " Select all 8,000 resultsCombine selected (0)", + "viewport": [ + 1280, + 720 + ] + } + ] +} diff --git a/docs/performance/tauri-experiments/05-baseline-refresh.json b/docs/performance/tauri-experiments/05-baseline-refresh.json new file mode 100644 index 0000000..edccc1c --- /dev/null +++ b/docs/performance/tauri-experiments/05-baseline-refresh.json @@ -0,0 +1,45 @@ +{ + "source": "0c09cf8", + "conditions": "Chromium development fixture, actual importer, 8000 generated batches, six invalidations 1ms apart; each mocked page has 15ms delay and real JSON encode/decode. No native IPC or account.", + "samples": [ + { + "durationMs": 180.70000004768372, + "queueCalls": 48, + "queueBytes": 15850986, + "renderMs": 3.8999998569488525, + "commits": 2, + "queueRows": 13, + "selectionLabel": " Select all 8,000 resultsCombine selected (0)", + "viewport": [ + 1280, + 720 + ] + }, + { + "durationMs": 166, + "queueCalls": 48, + "queueBytes": 15850986, + "renderMs": 3.799999952316284, + "commits": 2, + "queueRows": 13, + "selectionLabel": " Select all 8,000 resultsCombine selected (0)", + "viewport": [ + 1280, + 720 + ] + }, + { + "durationMs": 172.70000004768372, + "queueCalls": 48, + "queueBytes": 15850986, + "renderMs": 5.400000095367432, + "commits": 2, + "queueRows": 13, + "selectionLabel": " Select all 8,000 resultsCombine selected (0)", + "viewport": [ + 1280, + 720 + ] + } + ] +} diff --git a/docs/performance/tauri-experiments/README.md b/docs/performance/tauri-experiments/README.md new file mode 100644 index 0000000..aa8e472 --- /dev/null +++ b/docs/performance/tauri-experiments/README.md @@ -0,0 +1,403 @@ +# Tauri performance experiments + +Long-lived branch: `codex/tauri-performance-experiments`, starting at `7998670`. +Keep these changes separate from main until each result has been assessed. + +| Step | Experiment | Measurement and behavior checks | Status | +| --- | --- | --- | --- | +| 1 | Isolate elapsed-only playback presentation | Library and playlist React duration, long tasks, frame gaps, rendered scope; pause, seek, track changes, external playback, queue authority | Fixture comparison complete; combined native checks complete | +| 2 | Window large playlists; avoid rebuilding order/queue on unrelated renders | Opening, selection, scrolling, ticks, DOM count; duplicate entries, focus, multiselection, drag/reorder | Fixture comparison complete; combined native checks complete | +| 3 | Suspend hidden presentation and decorative animation | Visible/hidden/minimized CPU and render activity; fresh state on show, uninterrupted controller/audio work | Fixture and native visibility/minimize complete; no idle CPU win | +| 4a | Remove unnecessary startup settings writes | Warm/cold startup stage durations and write count; defaults and recovery | Store benchmark complete; combined native startup unchanged | +| 4b | Load only the selected window entry | Production bundle/startup timing for main and importer; first interaction | Bundle/startup fixture complete; no demonstrated latency gain | +| 4c | Move remaining eligible startup I/O off first-paint path | Native first-paint/usable-data timing, overlay/cache/playlist hydration, recovery-before-publication | File-load and native ready-view measurements complete; no startup win | +| 5 | Reduce full importer queue refresh work | Queue IPC count/bytes/time, first useful paint and refresh; global sort/filter, combine/selection, stale responses | Burst fixture complete; no native/account-bound performance claim | + +Measure at least three comparable samples before and after each discrete change. +Preserve raw data and exact source revisions. Browser fixtures and React timings +are separate from native WebKit and process measurements. An unmeasured or noisy +result is inconclusive, not a demonstrated improvement. Preserve negative +experiments in branch history and identify them explicitly. + +The browser playback fixture runs the real App and CSS with a deterministic +4,000-track Library and 4,100-entry playlist (100 deliberate duplicates). Its +native mock emits 12 authoritative player-state messages at one-second intervals, +matching the native backends' cadence. It performs no network, audio, or user-data +operations. Start from `apps/desktop` with: + +```sh +npm exec vite -- --config performance/vite.config.mjs +``` + +Open `http://127.0.0.1:5185/performance/`, wait for loading, and use the measurement +button. Repeat on the playlist. The visible JSON panel records React Profiler +commits/durations, animation-frame gaps, long tasks when supported, IPC count, +rendered rows, and the final elapsed display. + +## 1. Native elapsed presentation + +The experiment at `c504db219711d1b25f7133f939325a4d70fc86c2` sends native +position changes to a per-player store read by the transport. Semantic changes +and simulated playback retain the app reducer. No native controller or IPC +contract changed. + +| 12 one-second updates | Baseline median (range), ms | Candidate median (range), ms | +| --- | --- | --- | +| Library total React render duration | 303.4 (303.1–309.0) | 9.8 (9.3–10.0) | +| Playlist total React render duration | 3932.9 (3787.7–3938.1) | 9.0 (8.8–9.9) | +| Playlist largest animation-frame gap | 317.4 (301.0–450.6) | 33.3 (18.7–50.0) | + +Three samples per cell. Both variants ended at elapsed 12 with zero IPC calls +during ticks. Library retained 41 mounted rows and the playlist retained 4,100. +Playlist baseline had repeated long tasks (180–462 ms); all three candidate +samples had none. A regression check uses a counted Library DTO getter to prove +elapsed-only events do not re-read that view, while pause still does. The +baseline's second playlist commit per tick also disappears. + +Conditions: same Codex in-app Chromium session on the macOS arm64 host, 1280×720, +Vite development mode without StrictMode, no added CPU/network throttling, +signed-out deterministic native mock, warm loaded data, no service worker or +extension added by this fixture. Exact Chromium version and native WebKit costs +were not captured. Baseline Library source `cdb2648`, playlist source `8868b59` +(only its seed-row selector changed); candidate source `c504db2`. +Raw samples are the adjacent `01-{baseline,after}-{library,playlist}.json` files. + +Checks: 96 Node checks and 70 interaction tests passed (8 existing manual +benchmarks skipped), lint and production build passed, documentation check +passed after routing this report from AGENTS.md. The real fixture's keyboard +seek changed 12→13 and Pause changed the accessible control to Play. Existing +interaction coverage plus the added assertions exercise track replacement, +external playback, selection while playing, and stop resetting/disabling seek. + +Result: strong reduction within this fixture. The combined native checks below +confirm local playback and transport behavior; they do not isolate a native CPU +effect for this step. This is one of the two strongest candidates to adopt. + +## 2. Playlist windowing + +Baseline `22342c7`, candidate `04ad86a`, same Chromium fixture and conditions as +step 1. The candidate reuses the Library's fixed-row windowing, keeps the focused +row mounted, and memoizes the complete playlist order and playback queue. Selection +and mutations retain upstream positions; dragging computes its insertion position +from the complete list geometry with CSS zoom accounted for. + +| Action to two animation frames | Baseline median (range), ms | Candidate median (range), ms | +| --- | --- | --- | +| Open playlist | 769.7 (730.3–797.4) | 32.0 (32.0–50.4) | +| Select first row | 350.3 (333.9–370.3) | 31.8 (31.2–32.0) | +| End key | 420.7 (416.4–437.6) | 33.4 (33.3–33.4) | +| Shift+Home | 620.5 (590.3–628.0) | 33.3 (33.3–33.4) | +| Sort by name | 536.4 (525.3–582.3) | 33.3 (33.3–33.4) | +| Scroll to middle | 31.2 (30.9–33.0) | 33.3 (33.2–33.3) | + +Three samples in `02-{baseline,after}-interactions.json`. Mounted rows fall from +4,100 to 47–48; all candidate actions avoid long tasks. Scrolling was already +within one two-frame wait and shows no improvement; it now incurs a small React +render to replace visible rows. Two-frame timings have a roughly 33 ms floor at +this refresh rate, so they do not resolve smaller improvements. + +Checks: 96 Node checks and 71 interaction tests pass, lint/build pass. The new +4,000-entry regression crosses the viewport with End and Shift+Home, plays the +last duplicate with its distinct synthetic ID, removes the full selected range, +and verifies a drag at offset 2,000 sends global insertion index 2,002. Existing +Library window/focus checks still pass. Browser navigation at 760×600 retained +focus and correct position/total metadata through the last row; the normal +viewport was restored afterward. + +Result: strong fixture improvement for opening/selection/sort; scrolling unchanged +within the measurement's resolution. The combined native comparison below confirms +large WebKit interaction gains. This is the strongest candidate to adopt. + +## 3. Hidden presentation + +Baseline `71467f3`, candidate `ca9a4ee`. The fixture now uses a long first-track +title and an explicitly simulated document-visibility seam. This isolates the +visibility event contract; it does **not** simulate macOS occlusion, App Nap, +native minimization, or process CPU. + +Across three 12-tick samples, hidden React render work fell from 8.2 ms median +(7.7–9.0) to zero commits and zero render duration. The baseline title animation +remained running; the candidate animation was paused in all three samples. +The hidden transport retained its initial elapsed display, then immediately +displayed the latest native position (12) on visibility restoration and resumed +the animation. Raw data: `03-{baseline,after}-hidden.json`. + +The store continues accepting every position while hidden; semantic player +updates still use the reducer, and simulated playback/controller timing is +unchanged. Tests additionally cover hidden 11→42 updates staying unpainted until +show, without reading Library data. All 96 Node checks and 71 interaction tests, +lint, and production build pass. + +The isolated release builds and generated silent-audio fixture below now verify +real WebKit visibility delivery, elapsed refresh, and animation pause/resume for +hidden and minimized windows. Their idle CPU samples show no reduction; the CPU +scenario had playback paused and no scrolling title, so it does not measure the +active-animation optimization. + +## 4a. Startup settings writes + +Baseline `70c4a94` extracts the unchanged setup load/save sequence into +`FsSettingsStore::load_for_startup` and benchmarks that real boundary. Candidate +`eb7fae6` returns a valid loaded value directly, saving defaults only when absent. +The ignored `startup_settings_performance` release test measures three temporary +directories, each with one missing-file load and 100 existing-file loads. + +Existing-file load cost per invocation falls from 4.812 ms median +(4.674–4.851) to 0.045 ms (0.044–0.054). Missing-file/default creation remains a +real atomic write and is noisy: baseline 4.715 ms median (4.606–10.043), candidate +5.339 ms (4.900–11.578); no first-launch improvement is demonstrated. This is +warm local filesystem measurement, not a cold OS-cache or native launch result. +Raw samples: `04a-{baseline,after}-settings.json`. + +The regression proves missing defaults are persisted, valid existing bytes are +left unchanged, and invalid bytes remain intact while returning an error. +Existing-file startup performs zero settings saves; missing-file startup still +performs one. Normalization still occurs during load and future explicit writes. +The gain is about 4.77 ms at this boundary, so it should not be presented as a +large app-startup improvement. Native first-paint impact remains **UNVERIFIED**. + +## 4b. Window entry loading + +Baseline `67f09f6`, corrected candidate `48456a6`. The real `src/main.tsx` now +chooses one dynamic view loader; common theme/dialog CSS remains eager. The +first candidate (`280e840`) demonstrated a functional regression: Vite combined +the conditional loader's preload helper, requesting App dependencies for the +importer and omitting importer CSS. Its failed resource observations are kept +in `04b-rejected-conditional-loader.json`. Separate loader functions fix that +association; `performance/check-startup.mjs` checks both windows' observed +resource lists and specifically guards this regression. + +Production JavaScript required for either window falls from 446,859 bytes to +377,118 (main) / 377,075 (importer), about 15.6%. Independently gzipping each +required file with Python's default compression gives 130,008 bytes before and +112,673 / 112,533 after, about 13.3%. These are byte counts, not native memory +measurements. Manifests, file hashes, and sizes are in +`04b-{baseline,after}-bundle.json`. + +The production startup fixture uses the actual entry and views with deterministic +native responses, three warm-cache reloads per window, and the same 1280×720 +Chromium session. Initial cold navigations warm the resources and are excluded. + +| Window / metric | Baseline median (range), ms | Candidate median (range), ms | +| --- | --- | --- | +| Main DOM ready | 30.8 (29.7–33.3) | 31.0 (30.8–31.0) | +| Main ready + two animation frames | 51.1 (36.2–51.3) | 48.9 (42.7–54.2) | +| Importer DOM ready | 16.6 (14.9–18.4) | 17.2 (17.2–19.3) | +| Importer ready + two animation frames | 44.3 (34.6–44.6) | 49.0 (34.3–52.5) | + +No latency improvement clears the observed noise. Splitting adds dependency +requests and provides an explicit loading/error/retry UI. The corrected importer +requests its own stylesheet and no App chunk; main requests no importer chunk. +The importer shortcuts dialog opens correctly with keyboard focus. Three entry +tests cover selected-module isolation and accessible chunk-load failure; the +96 Node and 71 existing interaction checks also pass. Build, lint, documentation, +and Tauri ACL checks pass. + +Result: demonstrated per-window byte reduction; **no demonstrated startup speed +gain**. Keep the experiment for comparison, but do not merge it solely as a +latency optimization. Combined native startup measurements below also show no +readiness gain; importer first-use native cost was not measured. Raw timings are in +`04b-{baseline,after}-startup.json`. + +## 4c. Startup file hydration + +Baseline `78c76f9`, candidate `78ad327`. Recovery and initial overlay, membership, +playlist, settings, and cooldown reads now execute together on the blocking pool. +Setup can return while they run. The application dispatcher waits for successful +initialization, so no command receives an empty authoritative substitute. Recovery +failure rejects pending commands. Native menu/media composition stays on the main +thread; early exit waits for recovery before using the ordinary shutdown path. + +Three release-mode runs copy the same generated 4,000-track library/settings into +fresh temporary directories on the warm local filesystem. The baseline load call +occupies its caller for 10.448 ms median (9.535–14.910). The candidate returns a +pending future in less than 0.013 ms, and awaiting it takes 9.463 ms median +(8.349–10.726), compared with 10.448 ms baseline. These overlapping ranges do not +establish faster data readiness. They isolate file-load scheduling, **not** full +setup return time, rendering, native first paint, or cold disk startup. + +Startup checks pass for gating success/failure, abandoned initialization, +corrupt-library quarantine, invalid settings preservation, and existing journal +recovery. The combined native build launches, plays local files, installs its +menus, and exits normally. Physical media keys and an exit racing deliberately +stalled recovery were not exercised in native UI. This is the broadest experiment +(it moves composition behind the file load), and neither its small fixture cost +nor the combined native startup result justifies merging it. Raw data: +`04c-{baseline,after}-files.json`. + +## 5. Superseded importer refreshes + +Baseline `0c09cf8`, candidate `fea5335`. Both full and queue-only refresh callers +now pass their existing shared generation to the queue loader. After each page +arrives, an obsolete loader stops before requesting another page; the existing +publication guard still prevents its result from changing the UI. The newest +request retains every row for global sorting, filtering, selection, and combine. +There is no new native API, cache invalidation policy, or persisted format. + +The real importer fixture has 8,000 generated batches, 1,000 per response. A burst +sends six invalidations one millisecond apart; each mock page adds a fixed 15 ms +wait and performs real JSON encoding/decoding. In all three baseline samples this +causes 48 page calls and 15,850,986 bytes. All three candidate samples use 13 calls +and 4,283,276 bytes, a 73.0% reduction. The still-pending page from each obsolete +request finishes; the other seven pages are avoided. Mounted queue buttons stay +at 13, and global selection still sees 8,000 batches. + +Refresh-to-two-frames latency is unchanged within noise: 172.7 ms median baseline +(166.0–180.7), 173.1 ms candidate (169.9–174.2). Total React render duration is +small and slightly higher in this sample (3.9 vs 5.9 ms median), so this is a +reduction in wasted requests/bytes, **not** a demonstrated interaction speed gain. +Three candidate samples were collected after background build/test work finished; +one preliminary overlapping sample was excluded before collecting those three. +Raw data: `05-{baseline,after}-refresh.json`. + +Two focused checks prove obsolete loads stop after their pending page, never +publish, and leave the newest 2,100-row queue complete; pagination retry and +malformed-response rejection remain intact. Existing global filter/combine, +selection, and stale-response interaction tests pass. The browser filter reaches +batch 8,000 beyond the first response page. + +The ordinary isolated refresh still loads the complete queue. Revisioned or +incremental snapshots could reduce that cost, but this experiment does not +establish their value. Burst efficiency is demonstrated only in the fixture; +native IPC, memory, account-bound interaction, and steady-refresh benefit remain +**UNVERIFIED**. Start the existing performance Vite server and open +`/performance/importer.html?window=lastfm-importer&queueFixture=1` to reproduce. + +## Combined validation and current assessment + +All seven discrete experiments have before/after measurements in this directory. +The strongest fixture results are elapsed-render isolation and playlist windowing. +Avoiding the existing-settings rewrite saves about 4.77 ms per startup load. +Hidden presentation eliminates the remaining hidden transport work in the +visibility seam. Entry splitting reduces bytes, background file loading changes +scheduling, and importer cancellation reduces obsolete burst traffic; none of +those last three demonstrates faster readiness in the measured fixture. + +The complete Rust workspace tests, Rust formatting and lint, frontend tests +(96 Node checks plus 76 Vitest tests; 8 existing manual benchmarks skipped), +frontend lint/build, Tauri ACL/permission checks, release contract, and documentation +checks pass. A separate release-mode candidate bundle also builds successfully. +Browser checks confirm global importer filtering/selection through batch 8,000 +and dark playlist End navigation through row 4,100 at verified 760×600 and +1280×720 viewports. `validation.json` records scope and limitations. + +`recommendations.json` preserves the comparison inputs and full commit lineage. +The skill's structural CLI was run for each input without an evidence registry; +`structural-validation.json` records **UNVERIFIED** for all seven. No trusted +registry or user acceptance threshold was invented. These exploratory local +measurements support further evaluation, not an authenticated merge decision. + +## Combined native WebKit comparison + +The native complement is complete on this host. All seven discrete experiments +remain preserved; nothing has been merged or pushed. The original `main` checkout +is unchanged. These measurements compare the pre-step-1 baseline (`8868b59`) with +the final production code (`fea5335`, packaged from `ebaf788`), not individual +native effects of all seven changes. `native-manifest.json` records full source +revisions, bundle hashes, and the shared probe hash. + +Both are release-mode Tauri `.app` bundles with `dev-token-store`, identifier +`com.rianjs.retune.performance`, the real frontend entry and IPC, and the same +generated 4,000-track Library / 4,100-entry playlist. No Spotify or Last.fm account +was used. All playback used digital silence at app volume zero. Host: Apple M4 +Pro, 48 GiB RAM, macOS 26.6.2 (25G83), WebKit 21624.5.1.11.3. Main-window content +viewport was 1120×688. Neither build compilation nor the browser benchmarks ran +during the interaction samples. Other ordinary desktop processes remained running. + +`performance/native.vite.config.mjs` adds the opt-in bundled probe without +changing the production entry, authority, or CSP. Its button runs three identical +sequences against actual DOM controls. Open waits for IPC-populated playlist rows; +each action then waits two animation frames. These are ready-view proxies, not +hardware scanout timestamps, and have a roughly 33 ms frame floor. Browser-fixture +and native open durations therefore must not be directly pooled. + +| Native action | Before median, ms | After median, ms | +| --- | ---: | ---: | +| Open 4,100-entry playlist | 785 | 45 | +| Select first entry | 261 | 33 | +| End | 61 | 33 | +| Shift+Home | 125 | 33 | +| Sort by song | 707 | 33 | +| Scroll to middle | 31 | 34 | + +Three samples each, in `native-{before,after}.json`. Mounted playlist rows fell +from 4,100 to 45–46. End focused the same last duplicate in both versions; +Shift+Home and subsequent sort retained selection across the mounted window. +The separate interaction regression proves complete upstream positions remain +available for mutations. These observations support the playlist change strongly; +scrolling is unchanged within the measurement floor. + +Three warm launches per variant, after a discarded initial launch, measured +kernel process start through the populated Library plus two animation frames: +**607.6 ms before (594.5–666.1), 616.4 ms after (604.1–638.3)**. These ranges overlap. +`native-{before,after}-startup.json` records the samples and process counters. +This is neither cold-storage startup nor direct first-composited-frame evidence, +and it gives no reason to adopt the broader startup rewrite. Navigation timing +alone excludes native setup and must not be presented as whole-app startup. + +Idle samples used three consecutive five-second windows in each state after the +playlist sequence and brief playback had finished. `native-process.c` reads +`proc_pid_rusage`, converts Mach ticks with `mach_timebase_info`, and reads process +start from `PROC_PIDTBSDINFO`; its CPU units were cross-checked against `ps TIME`. +`native-sample.py` records raw counters for the app and its GPU, Networking, and +WebContent services. Services launched with the app and all exited with it; +pre-existing WebKit processes were excluded. CPU 100% means one core. + +| Paused native state | Before CPU % | After CPU % | Before summed footprint, MiB | After summed footprint, MiB | +| --- | ---: | ---: | ---: | ---: | +| Visible | 0.74 | 0.73 | 351.8 | 145.4 | +| Hidden | 0.74 | 0.90 | 349.6 | 145.8 | +| Minimized | 0.82 | 1.10 | 207.5 | 149.1 | + +There is **no demonstrated idle CPU saving**. The footprint snapshots suggest +lower retained memory after the playlist workload, but they are consecutive +samples from one process lifetime per variant, depend on garbage collection and +window state, and sum reported process footprints rather than unique system +memory. They are not three independent memory trials or a peak-memory claim. +Raw files are `native-{before,after}-{visible,hidden,minimized}-cpu.json`; +`native-summary.json` contains their medians. The first baseline CPU trial began +during brief playback and was discarded before collecting these paused samples. + +Native functional checks passed: local playback while signed out, automatic +pause after five seconds, hidden playback advancing from elapsed zero to four +without painting intervening positions, updated elapsed on show, hide/minimize +visibility delivery, and normal quit with all attributed processes gone. A +separate 900-pixel-wide window check exercised the scrolling title: running while +visible, paused after hide/minimize, running after show. The event and animation +states are in `native-after-marquee.json`. The tested apps are now stopped. +Physical media keys, OAuth, account-bound importer IPC, cold storage, and other +operating systems were not tested; this is a performance experiment, not release +certification. No authenticated registry status has changed. + +Reproduce the opt-in native build from `apps/desktop` with: + +```sh +rtk proxy npm exec tauri build -- --features dev-token-store --bundles app --config performance/native.config.json +rtk proxy clang -Wall -Wextra -Werror performance/native-process.c -o /tmp/native-process +rtk proxy python3 performance/native-sample.py /tmp/native-process /tmp/native-cpu.json PID GPU_PID NETWORK_PID WEB_PID +``` + +Seed only a fresh isolated `com.rianjs.retune.performance` data directory using +`performance/native_fixture.py`; it refuses an existing Library. Launch each +saved bundle through Computer Use, use the probe controls, and read its JSON +textarea through accessibility. For startup, subtract the main process's +`startEpochMs` from `navigationTimeOrigin + readyMs`. Preserve each sample before +quitting; verify the attributed processes exit between launches. + +## Adoption assessment + +1. **Elapsed presentation: adopt first.** Large isolated reduction in unnecessary + React work; no native playback regression found. +2. **Playlist windowing: adopt first.** Large isolated gains, confirmed by native + opening and sorting, with full-data keyboard/selection regression coverage. +3. **Hidden presentation: reasonable small improvement.** Removes measured hidden + React work and actually pauses WebKit animation; no battery/idle-CPU claim. +4. **Settings write removal: reasonable small improvement.** Avoids an unnecessary + atomic write, about 4.8 ms per warm existing-settings load in the microbenchmark. +5. **Lazy window entry: optional.** About 13% less gzip JS per window, no measured + startup speedup. Keep only if the byte reduction is worth the loader complexity. +6. **Async startup file load: defer.** Broadest lifecycle change and no measured + native startup gain on this Library size. +7. **Superseded importer refresh stop: reasonable small improvement.** 73% less + fixture IPC under a refresh burst, no faster newest-result readiness. It still + reads the full current queue and is not a general importer paging redesign. diff --git a/docs/performance/tauri-experiments/native-after-hidden-cpu.json b/docs/performance/tauri-experiments/native-after-hidden-cpu.json new file mode 100644 index 0000000..d1cddac --- /dev/null +++ b/docs/performance/tauri-experiments/native-after-hidden-cpu.json @@ -0,0 +1,173 @@ +[ + { + "durationMs": 5067.81375, + "before": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 1175912291, + "physicalFootprintBytes": 39371976 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 340053583, + "physicalFootprintBytes": 150176584 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 46290375, + "physicalFootprintBytes": 5685800 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1175732208, + "physicalFootprintBytes": 94438288 + } + ], + "after": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 1214463583, + "physicalFootprintBytes": 39273672 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 346140583, + "physicalFootprintBytes": 21611336 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 46759833, + "physicalFootprintBytes": 5685800 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1181914833, + "physicalFootprintBytes": 86852496 + } + ], + "cpuPercent": 1.0120808997765554, + "summedPhysicalFootprintBytes": 153423304 + }, + { + "durationMs": 5066.563958, + "before": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 1214841625, + "physicalFootprintBytes": 39273672 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 346140583, + "physicalFootprintBytes": 21611336 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 46759833, + "physicalFootprintBytes": 5685800 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1181914833, + "physicalFootprintBytes": 86852496 + } + ], + "after": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 1250858458, + "physicalFootprintBytes": 39208136 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 352781958, + "physicalFootprintBytes": 21218120 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 46759833, + "physicalFootprintBytes": 5685800 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1185065166, + "physicalFootprintBytes": 86770576 + } + ], + "cpuPercent": 0.9041342688997197, + "summedPhysicalFootprintBytes": 152882632 + }, + { + "durationMs": 5071.78975, + "before": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 1251176625, + "physicalFootprintBytes": 39208136 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 352781958, + "physicalFootprintBytes": 21218120 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 46759833, + "physicalFootprintBytes": 5685800 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1185135250, + "physicalFootprintBytes": 86770576 + } + ], + "after": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 1286473833, + "physicalFootprintBytes": 39224520 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 352950458, + "physicalFootprintBytes": 17269576 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 46829416, + "physicalFootprintBytes": 5669416 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1185459750, + "physicalFootprintBytes": 86754192 + } + ], + "cpuPercent": 0.7070441159356025, + "summedPhysicalFootprintBytes": 148917704 + } +] diff --git a/docs/performance/tauri-experiments/native-after-launch.json b/docs/performance/tauri-experiments/native-after-launch.json new file mode 100644 index 0000000..6623b2c --- /dev/null +++ b/docs/performance/tauri-experiments/native-after-launch.json @@ -0,0 +1 @@ +{"navigationTimeOrigin":1789003417566,"readyMs":184,"interactions":[],"visibility":[],"playback":[]} \ No newline at end of file diff --git a/docs/performance/tauri-experiments/native-after-marquee.json b/docs/performance/tauri-experiments/native-after-marquee.json new file mode 100644 index 0000000..bd617b3 --- /dev/null +++ b/docs/performance/tauri-experiments/native-after-marquee.json @@ -0,0 +1,87 @@ +{ + "navigationTimeOrigin": 1789003443482, + "readyMs": 179, + "interactions": [], + "visibility": [ + { + "atMs": 49843, + "state": "hidden", + "elapsed": "4", + "animationStates": [ + "running" + ] + }, + { + "atMs": 49943, + "state": "hidden", + "elapsed": "4", + "animationStates": [ + "paused" + ] + }, + { + "atMs": 63386, + "state": "visible", + "elapsed": "4", + "animationStates": [ + "paused" + ] + }, + { + "atMs": 63488, + "state": "visible", + "elapsed": "4", + "animationStates": [ + "running" + ] + }, + { + "atMs": 64522.00000000001, + "state": "hidden", + "elapsed": "4", + "animationStates": [ + "running" + ] + }, + { + "atMs": 64622, + "state": "hidden", + "elapsed": "4", + "animationStates": [ + "paused" + ] + }, + { + "atMs": 93811, + "state": "visible", + "elapsed": "4", + "animationStates": [ + "paused" + ] + }, + { + "atMs": 93912, + "state": "visible", + "elapsed": "4", + "animationStates": [ + "running" + ] + } + ], + "playback": [ + { + "atMs": 31342.000000000004, + "state": "visible", + "elapsed": "0", + "animationStates": [] + }, + { + "atMs": 36344, + "state": "visible", + "elapsed": "4", + "animationStates": [ + "running" + ] + } + ] +} \ No newline at end of file diff --git a/docs/performance/tauri-experiments/native-after-minimized-cpu.json b/docs/performance/tauri-experiments/native-after-minimized-cpu.json new file mode 100644 index 0000000..14aab38 --- /dev/null +++ b/docs/performance/tauri-experiments/native-after-minimized-cpu.json @@ -0,0 +1,173 @@ +[ + { + "durationMs": 5064.781291, + "before": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 1561820666, + "physicalFootprintBytes": 39339208 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 369674333, + "physicalFootprintBytes": 144655176 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 47912666, + "physicalFootprintBytes": 5718568 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1299321750, + "physicalFootprintBytes": 93438864 + } + ], + "after": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 1603042833, + "physicalFootprintBytes": 39257288 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 391786625, + "physicalFootprintBytes": 22201184 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 48601791, + "physicalFootprintBytes": 5702184 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1313739291, + "physicalFootprintBytes": 90833808 + } + ], + "cpuPercent": 1.5487564120367463, + "summedPhysicalFootprintBytes": 157994464 + }, + { + "durationMs": 5064.80375, + "before": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 1603342875, + "physicalFootprintBytes": 39257288 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 391786625, + "physicalFootprintBytes": 22201184 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 48601791, + "physicalFootprintBytes": 5702184 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1313944166, + "physicalFootprintBytes": 90833808 + } + ], + "after": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 1639265166, + "physicalFootprintBytes": 39257288 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 402672291, + "physicalFootprintBytes": 21349216 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 48601791, + "physicalFootprintBytes": 5702184 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1322720708, + "physicalFootprintBytes": 90080144 + } + ], + "cpuPercent": 1.097465997571969, + "summedPhysicalFootprintBytes": 156388832 + }, + { + "durationMs": 5057.165791, + "before": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 1639612291, + "physicalFootprintBytes": 39257288 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 402672291, + "physicalFootprintBytes": 21349216 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 48601791, + "physicalFootprintBytes": 5702184 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1322720708, + "physicalFootprintBytes": 90080144 + } + ], + "after": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 1676991625, + "physicalFootprintBytes": 39273672 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 408957291, + "physicalFootprintBytes": 17253216 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 48679833, + "physicalFootprintBytes": 5685800 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1330234416, + "physicalFootprintBytes": 90080144 + } + ], + "cpuPercent": 1.0135337878623247, + "summedPhysicalFootprintBytes": 152292832 + } +] diff --git a/docs/performance/tauri-experiments/native-after-startup.json b/docs/performance/tauri-experiments/native-after-startup.json new file mode 100644 index 0000000..f909d1b --- /dev/null +++ b/docs/performance/tauri-experiments/native-after-startup.json @@ -0,0 +1,107 @@ +[ + { + "navigationTimeOrigin": 1789003414852, + "navigationReadyMs": 158, + "processToLibraryReadyMs": 638.344970703125, + "processes": [ + { + "comm": "/private/tmp/retune-performance-experiments/apps/native-after/Retune Performance.app/Contents/MacOS/retune-desktop", + "pid": 74437, + "startEpochMs": 1789003414371.655, + "cpuNs": 370724500, + "physicalFootprintBytes": 62014616 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.GPU.xpc/Contents/MacOS/com.apple.WebKit.GPU", + "pid": 74438, + "startEpochMs": 1789003414586.973, + "cpuNs": 110898541, + "physicalFootprintBytes": 178914024 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.Networking.xpc/Contents/MacOS/com.apple.WebKit.Networking", + "pid": 74439, + "startEpochMs": 1789003414599.946, + "cpuNs": 30059625, + "physicalFootprintBytes": 7406096 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.WebContent.xpc/Contents/MacOS/com.apple.WebKit.WebContent", + "pid": 74440, + "startEpochMs": 1789003414602.737, + "cpuNs": 213340416, + "physicalFootprintBytes": 101925776 + } + ] + }, + { + "navigationTimeOrigin": 1789003416194, + "navigationReadyMs": 167, + "processToLibraryReadyMs": 604.083984375, + "processes": [ + { + "comm": "/private/tmp/retune-performance-experiments/apps/native-after/Retune Performance.app/Contents/MacOS/retune-desktop", + "pid": 74471, + "startEpochMs": 1789003415756.916, + "cpuNs": 371701583, + "physicalFootprintBytes": 61637808 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.GPU.xpc/Contents/MacOS/com.apple.WebKit.GPU", + "pid": 74473, + "startEpochMs": 1789003415933.683, + "cpuNs": 108663833, + "physicalFootprintBytes": 178619160 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.Networking.xpc/Contents/MacOS/com.apple.WebKit.Networking", + "pid": 74474, + "startEpochMs": 1789003415949.348, + "cpuNs": 31656041, + "physicalFootprintBytes": 7307768 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.WebContent.xpc/Contents/MacOS/com.apple.WebKit.WebContent", + "pid": 74475, + "startEpochMs": 1789003415954.544, + "cpuNs": 211774958, + "physicalFootprintBytes": 90260296 + } + ] + }, + { + "navigationTimeOrigin": 1789003417566, + "navigationReadyMs": 184, + "processToLibraryReadyMs": 616.43798828125, + "processes": [ + { + "comm": "/private/tmp/retune-performance-experiments/apps/native-after/Retune Performance.app/Contents/MacOS/retune-desktop", + "pid": 74503, + "startEpochMs": 1789003417133.562, + "cpuNs": 363187750, + "physicalFootprintBytes": 59966640 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.GPU.xpc/Contents/MacOS/com.apple.WebKit.GPU", + "pid": 74504, + "startEpochMs": 1789003417315.713, + "cpuNs": 108583875, + "physicalFootprintBytes": 180994792 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.Networking.xpc/Contents/MacOS/com.apple.WebKit.Networking", + "pid": 74505, + "startEpochMs": 1789003417327.57, + "cpuNs": 29477708, + "physicalFootprintBytes": 7520784 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.WebContent.xpc/Contents/MacOS/com.apple.WebKit.WebContent", + "pid": 74506, + "startEpochMs": 1789003417330.533, + "cpuNs": 219171041, + "physicalFootprintBytes": 100254632 + } + ] + } +] diff --git a/docs/performance/tauri-experiments/native-after-visible-cpu.json b/docs/performance/tauri-experiments/native-after-visible-cpu.json new file mode 100644 index 0000000..282407f --- /dev/null +++ b/docs/performance/tauri-experiments/native-after-visible-cpu.json @@ -0,0 +1,173 @@ +[ + { + "durationMs": 5049.207459, + "before": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 908423500, + "physicalFootprintBytes": 39240904 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 322190708, + "physicalFootprintBytes": 142099272 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 45602500, + "physicalFootprintBytes": 5685800 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1135612583, + "physicalFootprintBytes": 93914000 + } + ], + "after": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 958049208, + "physicalFootprintBytes": 39273672 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 325039500, + "physicalFootprintBytes": 19006280 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 45853625, + "physicalFootprintBytes": 5685800 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1139089291, + "physicalFootprintBytes": 88572816 + } + ], + "cpuPercent": 1.113092172511583, + "summedPhysicalFootprintBytes": 152538568 + }, + { + "durationMs": 5067.282416, + "before": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 958231583, + "physicalFootprintBytes": 39273672 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 325039500, + "physicalFootprintBytes": 19006280 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 45853625, + "physicalFootprintBytes": 5685800 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1139089291, + "physicalFootprintBytes": 88572816 + } + ], + "after": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 993930041, + "physicalFootprintBytes": 39158984 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 325154125, + "physicalFootprintBytes": 19006280 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 45853625, + "physicalFootprintBytes": 5685800 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1139089291, + "physicalFootprintBytes": 88572816 + } + ], + "cpuPercent": 0.7067512733634067, + "summedPhysicalFootprintBytes": 152423880 + }, + { + "durationMs": 5060.951417, + "before": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 994258041, + "physicalFootprintBytes": 39158984 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 325154125, + "physicalFootprintBytes": 19006280 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 45853625, + "physicalFootprintBytes": 5685800 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1139089291, + "physicalFootprintBytes": 88572816 + } + ], + "after": [ + { + "pid": 74118, + "startEpochMs": 1789003229952.655, + "cpuNs": 1030747083, + "physicalFootprintBytes": 39175368 + }, + { + "pid": 74119, + "startEpochMs": 1789003230768.242, + "cpuNs": 325287416, + "physicalFootprintBytes": 17023816 + }, + { + "pid": 74120, + "startEpochMs": 1789003230781.02, + "cpuNs": 45917041, + "physicalFootprintBytes": 5669416 + }, + { + "pid": 74121, + "startEpochMs": 1789003230784.778, + "cpuNs": 1139156833, + "physicalFootprintBytes": 88556432 + } + ], + "cpuPercent": 0.7262130767852024, + "summedPhysicalFootprintBytes": 150425032 + } +] diff --git a/docs/performance/tauri-experiments/native-after.json b/docs/performance/tauri-experiments/native-after.json new file mode 100644 index 0000000..05895e8 --- /dev/null +++ b/docs/performance/tauri-experiments/native-after.json @@ -0,0 +1,244 @@ +{ + "navigationTimeOrigin": 1789003231020, + "readyMs": 169, + "interactions": [ + { + "viewport": [ + 1120, + 688 + ], + "actions": [ + { + "name": "open", + "durationMs": 50.99999999999909, + "mountedRows": 45, + "selectedRows": 0, + "focusedRow": null + }, + { + "name": "select", + "durationMs": 33, + "mountedRows": 45, + "selectedRows": 1, + "focusedRow": null + }, + { + "name": "end", + "durationMs": 34, + "mountedRows": 45, + "selectedRows": 1, + "focusedRow": "Track 00099 — long playback title for native marquee measurement by Artist 099" + }, + { + "name": "shift-home", + "durationMs": 33, + "mountedRows": 45, + "selectedRows": 45, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + }, + { + "name": "sort", + "durationMs": 32, + "mountedRows": 45, + "selectedRows": 45, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + }, + { + "name": "scroll-middle", + "durationMs": 34, + "mountedRows": 46, + "selectedRows": 46, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + } + ] + }, + { + "viewport": [ + 1120, + 688 + ], + "actions": [ + { + "name": "open", + "durationMs": 45, + "mountedRows": 45, + "selectedRows": 0, + "focusedRow": null + }, + { + "name": "select", + "durationMs": 32.99999999999909, + "mountedRows": 45, + "selectedRows": 1, + "focusedRow": null + }, + { + "name": "end", + "durationMs": 33, + "mountedRows": 45, + "selectedRows": 1, + "focusedRow": "Track 00099 — long playback title for native marquee measurement by Artist 099" + }, + { + "name": "shift-home", + "durationMs": 34, + "mountedRows": 45, + "selectedRows": 45, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + }, + { + "name": "sort", + "durationMs": 33.00000000000091, + "mountedRows": 45, + "selectedRows": 45, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + }, + { + "name": "scroll-middle", + "durationMs": 32, + "mountedRows": 46, + "selectedRows": 46, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + } + ] + }, + { + "viewport": [ + 1120, + 688 + ], + "actions": [ + { + "name": "open", + "durationMs": 42, + "mountedRows": 45, + "selectedRows": 0, + "focusedRow": null + }, + { + "name": "select", + "durationMs": 34, + "mountedRows": 45, + "selectedRows": 1, + "focusedRow": null + }, + { + "name": "end", + "durationMs": 33, + "mountedRows": 45, + "selectedRows": 1, + "focusedRow": "Track 00099 — long playback title for native marquee measurement by Artist 099" + }, + { + "name": "shift-home", + "durationMs": 33.00000000000091, + "mountedRows": 45, + "selectedRows": 45, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + }, + { + "name": "sort", + "durationMs": 32.99999999999909, + "mountedRows": 45, + "selectedRows": 45, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + }, + { + "name": "scroll-middle", + "durationMs": 34, + "mountedRows": 46, + "selectedRows": 46, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + } + ] + } + ], + "visibility": [ + { + "atMs": 34683, + "state": "hidden", + "elapsed": "0", + "animationStates": [] + }, + { + "atMs": 34784, + "state": "hidden", + "elapsed": "0", + "animationStates": [] + }, + { + "atMs": 60473, + "state": "visible", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 60574, + "state": "visible", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 92473, + "state": "hidden", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 92574, + "state": "hidden", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 137797, + "state": "visible", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 137899, + "state": "visible", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 138920.00000000003, + "state": "hidden", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 139022, + "state": "hidden", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 168369, + "state": "visible", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 168470, + "state": "visible", + "elapsed": "4", + "animationStates": [] + } + ], + "playback": [ + { + "atMs": 34266, + "state": "visible", + "elapsed": "0", + "animationStates": [] + }, + { + "atMs": 39267, + "state": "hidden", + "elapsed": "0", + "animationStates": [] + } + ] +} \ No newline at end of file diff --git a/docs/performance/tauri-experiments/native-before-hidden-cpu.json b/docs/performance/tauri-experiments/native-before-hidden-cpu.json new file mode 100644 index 0000000..fdef116 --- /dev/null +++ b/docs/performance/tauri-experiments/native-before-hidden-cpu.json @@ -0,0 +1,173 @@ +[ + { + "durationMs": 5064.743083, + "before": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 1099602083, + "physicalFootprintBytes": 49169632 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 317215125, + "physicalFootprintBytes": 143983384 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 48862916, + "physicalFootprintBytes": 5882384 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7470250541, + "physicalFootprintBytes": 292816360 + } + ], + "after": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 1136698250, + "physicalFootprintBytes": 49136864 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 320677833, + "physicalFootprintBytes": 21070616 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 49500083, + "physicalFootprintBytes": 5882384 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7474207541, + "physicalFootprintBytes": 290555368 + } + ], + "cpuPercent": 0.8915169290927684, + "summedPhysicalFootprintBytes": 366645232 + }, + { + "durationMs": 5057.86275, + "before": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 1137057250, + "physicalFootprintBytes": 49136864 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 320677833, + "physicalFootprintBytes": 21070616 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 49500083, + "physicalFootprintBytes": 5882384 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7474207541, + "physicalFootprintBytes": 290555368 + } + ], + "after": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 1173310458, + "physicalFootprintBytes": 49087712 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 320761583, + "physicalFootprintBytes": 21054232 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 49500083, + "physicalFootprintBytes": 5882384 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7475304958, + "physicalFootprintBytes": 290522600 + } + ], + "cpuPercent": 0.7401223965596931, + "summedPhysicalFootprintBytes": 366546928 + }, + { + "durationMs": 5041.90925, + "before": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 1173507458, + "physicalFootprintBytes": 49087712 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 320796666, + "physicalFootprintBytes": 21054232 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 49500083, + "physicalFootprintBytes": 5882384 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7475304958, + "physicalFootprintBytes": 290522600 + } + ], + "after": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 1203704166, + "physicalFootprintBytes": 49087712 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 320916625, + "physicalFootprintBytes": 17105688 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 49624583, + "physicalFootprintBytes": 5866000 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7475759208, + "physicalFootprintBytes": 290522600 + } + ], + "cpuPercent": 0.6127721755404463, + "summedPhysicalFootprintBytes": 362582000 + } +] diff --git a/docs/performance/tauri-experiments/native-before-launch.json b/docs/performance/tauri-experiments/native-before-launch.json new file mode 100644 index 0000000..8a5060f --- /dev/null +++ b/docs/performance/tauri-experiments/native-before-launch.json @@ -0,0 +1 @@ +{"navigationTimeOrigin":1789003219161,"readyMs":95,"interactions":[],"visibility":[],"playback":[]} \ No newline at end of file diff --git a/docs/performance/tauri-experiments/native-before-minimized-cpu.json b/docs/performance/tauri-experiments/native-before-minimized-cpu.json new file mode 100644 index 0000000..499b333 --- /dev/null +++ b/docs/performance/tauri-experiments/native-before-minimized-cpu.json @@ -0,0 +1,173 @@ +[ + { + "durationMs": 5069.798584, + "before": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 1544516916, + "physicalFootprintBytes": 41960672 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 342368875, + "physicalFootprintBytes": 143885080 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 50609125, + "physicalFootprintBytes": 5882384 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7748992458, + "physicalFootprintBytes": 152782312 + } + ], + "after": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 1588682541, + "physicalFootprintBytes": 41813216 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 346236333, + "physicalFootprintBytes": 20890392 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 51229666, + "physicalFootprintBytes": 5882384 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7756183500, + "physicalFootprintBytes": 149013992 + } + ], + "cpuPercent": 1.1015164621380154, + "summedPhysicalFootprintBytes": 217599984 + }, + { + "durationMs": 5070.823541, + "before": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 1589087041, + "physicalFootprintBytes": 41813216 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 346236333, + "physicalFootprintBytes": 20890392 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 51229666, + "physicalFootprintBytes": 5882384 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7756276791, + "physicalFootprintBytes": 149013992 + } + ], + "after": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 1628980166, + "physicalFootprintBytes": 41813216 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 346305958, + "physicalFootprintBytes": 20890392 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 51229666, + "physicalFootprintBytes": 5882384 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7757816500, + "physicalFootprintBytes": 148948456 + } + ], + "cpuPercent": 0.8184559897309194, + "summedPhysicalFootprintBytes": 217534448 + }, + { + "durationMs": 5060.497166, + "before": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 1629439166, + "physicalFootprintBytes": 41813216 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 346305958, + "physicalFootprintBytes": 20890392 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 51229666, + "physicalFootprintBytes": 5882384 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7757816500, + "physicalFootprintBytes": 148948456 + } + ], + "after": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 1669576458, + "physicalFootprintBytes": 41780448 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 346533708, + "physicalFootprintBytes": 16941848 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 51334750, + "physicalFootprintBytes": 5866000 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7758267791, + "physicalFootprintBytes": 148948456 + } + ], + "cpuPercent": 0.8086442034774574, + "summedPhysicalFootprintBytes": 213536752 + } +] diff --git a/docs/performance/tauri-experiments/native-before-startup.json b/docs/performance/tauri-experiments/native-before-startup.json new file mode 100644 index 0000000..889555e --- /dev/null +++ b/docs/performance/tauri-experiments/native-before-startup.json @@ -0,0 +1,104 @@ +[ + { + "navigationReadyMs": 162, + "processToLibraryReadyMs": 666.1201171875, + "processes": [ + { + "comm": "/private/tmp/retune-performance-experiments/apps/native-before/Retune Performance.app/Contents/MacOS/retune-desktop", + "pid": 73990, + "startEpochMs": 1789003215788.88, + "cpuNs": 367529041, + "physicalFootprintBytes": 61850800 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.GPU.xpc/Contents/MacOS/com.apple.WebKit.GPU", + "pid": 73991, + "startEpochMs": 1789003216008.328, + "cpuNs": 103272541, + "physicalFootprintBytes": 178488112 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.Networking.xpc/Contents/MacOS/com.apple.WebKit.Networking", + "pid": 73992, + "startEpochMs": 1789003216023.352, + "cpuNs": 29130666, + "physicalFootprintBytes": 7143976 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.WebContent.xpc/Contents/MacOS/com.apple.WebKit.WebContent", + "pid": 73993, + "startEpochMs": 1789003216026.036, + "cpuNs": 205702458, + "physicalFootprintBytes": 93225776 + } + ] + }, + { + "navigationReadyMs": 96, + "processToLibraryReadyMs": 607.6279296875, + "processes": [ + { + "comm": "/private/tmp/retune-performance-experiments/apps/native-before/Retune Performance.app/Contents/MacOS/retune-desktop", + "pid": 74024, + "startEpochMs": 1789003217268.372, + "cpuNs": 358452208, + "physicalFootprintBytes": 59999360 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.GPU.xpc/Contents/MacOS/com.apple.WebKit.GPU", + "pid": 74026, + "startEpochMs": 1789003217441.098, + "cpuNs": 103738375, + "physicalFootprintBytes": 180536064 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.Networking.xpc/Contents/MacOS/com.apple.WebKit.Networking", + "pid": 74027, + "startEpochMs": 1789003217451.436, + "cpuNs": 30040958, + "physicalFootprintBytes": 7569936 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.WebContent.xpc/Contents/MacOS/com.apple.WebKit.WebContent", + "pid": 74028, + "startEpochMs": 1789003217454.153, + "cpuNs": 215841541, + "physicalFootprintBytes": 93930288 + } + ] + }, + { + "navigationReadyMs": 95, + "processToLibraryReadyMs": 594.52392578125, + "processes": [ + { + "comm": "/private/tmp/retune-performance-experiments/apps/native-before/Retune Performance.app/Contents/MacOS/retune-desktop", + "pid": 74054, + "startEpochMs": 1789003218661.476, + "cpuNs": 360115291, + "physicalFootprintBytes": 61473968 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.GPU.xpc/Contents/MacOS/com.apple.WebKit.GPU", + "pid": 74075, + "startEpochMs": 1789003218832.663, + "cpuNs": 102050458, + "physicalFootprintBytes": 180470456 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.Networking.xpc/Contents/MacOS/com.apple.WebKit.Networking", + "pid": 74076, + "startEpochMs": 1789003218844.519, + "cpuNs": 28764916, + "physicalFootprintBytes": 7340560 + }, + { + "comm": "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.WebContent.xpc/Contents/MacOS/com.apple.WebKit.WebContent", + "pid": 74077, + "startEpochMs": 1789003218847.806, + "cpuNs": 211007875, + "physicalFootprintBytes": 93569912 + } + ] + } +] diff --git a/docs/performance/tauri-experiments/native-before-visible-cpu.json b/docs/performance/tauri-experiments/native-before-visible-cpu.json new file mode 100644 index 0000000..ae36d9f --- /dev/null +++ b/docs/performance/tauri-experiments/native-before-visible-cpu.json @@ -0,0 +1,173 @@ +[ + { + "durationMs": 5053.721416, + "before": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 847659041, + "physicalFootprintBytes": 55723208 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 310375541, + "physicalFootprintBytes": 16761624 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 48742958, + "physicalFootprintBytes": 5866000 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7445701625, + "physicalFootprintBytes": 290489832 + } + ], + "after": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 884732875, + "physicalFootprintBytes": 55755976 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 310510583, + "physicalFootprintBytes": 16761624 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 48742958, + "physicalFootprintBytes": 5866000 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7445891041, + "physicalFootprintBytes": 290473448 + } + ], + "cpuPercent": 0.7400149102322422, + "summedPhysicalFootprintBytes": 368857048 + }, + { + "durationMs": 5060.261125, + "before": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 885002750, + "physicalFootprintBytes": 55755976 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 310510583, + "physicalFootprintBytes": 16761624 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 48742958, + "physicalFootprintBytes": 5866000 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7445891041, + "physicalFootprintBytes": 290473448 + } + ], + "after": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 920371333, + "physicalFootprintBytes": 55755976 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 310622250, + "physicalFootprintBytes": 16761624 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 48742958, + "physicalFootprintBytes": 5866000 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7445891041, + "physicalFootprintBytes": 290473448 + } + ], + "cpuPercent": 0.7011545278703379, + "summedPhysicalFootprintBytes": 368857048 + }, + { + "durationMs": 5061.138, + "before": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 920696125, + "physicalFootprintBytes": 55755976 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 310622250, + "physicalFootprintBytes": 16761624 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 48742958, + "physicalFootprintBytes": 5866000 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7445891041, + "physicalFootprintBytes": 290473448 + } + ], + "after": [ + { + "pid": 73537, + "startEpochMs": 1789002969142.336, + "cpuNs": 979317208, + "physicalFootprintBytes": 55821512 + }, + { + "pid": 73550, + "startEpochMs": 1789002969936.146, + "cpuNs": 310681833, + "physicalFootprintBytes": 16761624 + }, + { + "pid": 73551, + "startEpochMs": 1789002969946.463, + "cpuNs": 48742958, + "physicalFootprintBytes": 5866000 + }, + { + "pid": 73552, + "startEpochMs": 1789002969949.927, + "cpuNs": 7462846500, + "physicalFootprintBytes": 290489832 + } + ], + "cpuPercent": 1.4944489757046737, + "summedPhysicalFootprintBytes": 368938968 + } +] diff --git a/docs/performance/tauri-experiments/native-before.json b/docs/performance/tauri-experiments/native-before.json new file mode 100644 index 0000000..b9009ca --- /dev/null +++ b/docs/performance/tauri-experiments/native-before.json @@ -0,0 +1,220 @@ +{ + "navigationTimeOrigin": 1789002970230, + "readyMs": 156, + "interactions": [ + { + "viewport": [ + 1120, + 688 + ], + "actions": [ + { + "name": "open", + "durationMs": 798, + "mountedRows": 4100, + "selectedRows": 0, + "focusedRow": null + }, + { + "name": "select", + "durationMs": 270.0000000000073, + "mountedRows": 4100, + "selectedRows": 1, + "focusedRow": null + }, + { + "name": "end", + "durationMs": 61, + "mountedRows": 4100, + "selectedRows": 1, + "focusedRow": "Track 00099 — long playback title for native marquee measurement by Artist 099" + }, + { + "name": "shift-home", + "durationMs": 125, + "mountedRows": 4100, + "selectedRows": 4100, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + }, + { + "name": "sort", + "durationMs": 707, + "mountedRows": 4100, + "selectedRows": 4100, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + }, + { + "name": "scroll-middle", + "durationMs": 30, + "mountedRows": 4100, + "selectedRows": 4100, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + } + ] + }, + { + "viewport": [ + 1120, + 688 + ], + "actions": [ + { + "name": "open", + "durationMs": 785, + "mountedRows": 4100, + "selectedRows": 0, + "focusedRow": null + }, + { + "name": "select", + "durationMs": 261, + "mountedRows": 4100, + "selectedRows": 1, + "focusedRow": null + }, + { + "name": "end", + "durationMs": 66.00000000000728, + "mountedRows": 4100, + "selectedRows": 1, + "focusedRow": "Track 00099 — long playback title for native marquee measurement by Artist 099" + }, + { + "name": "shift-home", + "durationMs": 127.00000000000728, + "mountedRows": 4100, + "selectedRows": 4100, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + }, + { + "name": "sort", + "durationMs": 725, + "mountedRows": 4100, + "selectedRows": 4100, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + }, + { + "name": "scroll-middle", + "durationMs": 31, + "mountedRows": 4100, + "selectedRows": 4100, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + } + ] + }, + { + "viewport": [ + 1120, + 688 + ], + "actions": [ + { + "name": "open", + "durationMs": 777, + "mountedRows": 4100, + "selectedRows": 0, + "focusedRow": null + }, + { + "name": "select", + "durationMs": 257.00000000001455, + "mountedRows": 4100, + "selectedRows": 1, + "focusedRow": null + }, + { + "name": "end", + "durationMs": 57, + "mountedRows": 4100, + "selectedRows": 1, + "focusedRow": "Track 00099 — long playback title for native marquee measurement by Artist 099" + }, + { + "name": "shift-home", + "durationMs": 116, + "mountedRows": 4100, + "selectedRows": 4100, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + }, + { + "name": "sort", + "durationMs": 692, + "mountedRows": 4100, + "selectedRows": 4100, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + }, + { + "name": "scroll-middle", + "durationMs": 31.999999999985448, + "mountedRows": 4100, + "selectedRows": 4100, + "focusedRow": "Track 00000 — long playback title for native marquee measurement by Artist 000" + } + ] + } + ], + "visibility": [ + { + "atMs": 144125, + "state": "hidden", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 144226, + "state": "hidden", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 182024, + "state": "visible", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 182125, + "state": "visible", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 191237, + "state": "hidden", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 191338, + "state": "hidden", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 223029, + "state": "visible", + "elapsed": "4", + "animationStates": [] + }, + { + "atMs": 223130, + "state": "visible", + "elapsed": "4", + "animationStates": [] + } + ], + "playback": [ + { + "atMs": 94427, + "state": "visible", + "elapsed": "0", + "animationStates": [] + }, + { + "atMs": 99429, + "state": "visible", + "elapsed": "4", + "animationStates": [] + } + ] +} \ No newline at end of file diff --git a/docs/performance/tauri-experiments/native-candidate.json b/docs/performance/tauri-experiments/native-candidate.json new file mode 100644 index 0000000..e1699a7 --- /dev/null +++ b/docs/performance/tauri-experiments/native-candidate.json @@ -0,0 +1,7 @@ +{ + "source": "fea5335577b5a2b78de98834fcec32fb8dcf5038", + "bundle": "/tmp/retune-performance-experiments/apps/final/Retune Performance.app", + "executableSha256": "ace0ec45559a07c8484d03e856301e91d3bded57c89983d33de92fa028b8b9d0", + "build": "release; dev-token-store; performance/native.config.json", + "runtimeValidation": "UNVERIFIED: Mac locked; bundle not launched" +} diff --git a/docs/performance/tauri-experiments/native-manifest.json b/docs/performance/tauri-experiments/native-manifest.json new file mode 100644 index 0000000..77c5961 --- /dev/null +++ b/docs/performance/tauri-experiments/native-manifest.json @@ -0,0 +1,28 @@ +{ + "platform": "ProductName:\t\tmacOS\nProductVersion:\t\t26.6.2\nBuildVersion:\t\t25G83\n", + "harnessSha256": "95db9448c9799247c721f3d7e3438e4f422e108d0a46a2e4e2855bc6fd02d2ce", + "fixture": { + "tracks": 4000, + "playlistEntries": 4100, + "volume": 0 + }, + "variants": { + "before": { + "sourceCommit": "8868b590b05efd5046367702f11acff9b95a1b05", + "app": "/tmp/retune-performance-experiments/apps/native-before/Retune Performance.app", + "binarySha256": "55288c7f7d978f544b730b9aca5a24eada608b8690d8bed00e00bccb3ab04ade" + }, + "after": { + "sourceCommit": "ebaf7883b7b0f357503dde59b012af987b7c7466", + "app": "/tmp/retune-performance-experiments/apps/native-after/Retune Performance.app", + "binarySha256": "7f80a47bd23808f57931eada7835bdaf9454d69fe78a2b9ea516f5f1decc5e95" + } + }, + "hardware": { + "cpu": "Apple M4 Pro", + "memoryBytes": 51539607552 + }, + "webKitVersion": "21624.5.1.11.3", + "webKitVersionSource": "macOS sample header from native baseline WebContent process", + "scope": "Opt-in identical native-probe.js, actual App and IPC. Source revisions differ in seven production experiments; measurements are combined native comparison." +} diff --git a/docs/performance/tauri-experiments/native-summary.json b/docs/performance/tauri-experiments/native-summary.json new file mode 100644 index 0000000..51da2d5 --- /dev/null +++ b/docs/performance/tauri-experiments/native-summary.json @@ -0,0 +1,60 @@ +{ + "before": { + "playlist": { + "open": 785, + "select": 261, + "end": 61, + "shift-home": 125, + "sort": 707, + "scroll-middle": 31 + }, + "startup": { + "medianMs": 607.6279296875, + "minMs": 594.52392578125, + "maxMs": 666.1201171875 + }, + "idle": { + "visible": { + "cpuPercent": 0.7400149102322422, + "summedPhysicalFootprintMiB": 351.76949310302734 + }, + "hidden": { + "cpuPercent": 0.7401223965596931, + "summedPhysicalFootprintMiB": 349.56639099121094 + }, + "minimized": { + "cpuPercent": 0.8184559897309194, + "summedPhysicalFootprintMiB": 207.45701599121094 + } + } + }, + "after": { + "playlist": { + "open": 45, + "select": 33, + "end": 33, + "shift-home": 33.00000000000091, + "sort": 32.99999999999909, + "scroll-middle": 34 + }, + "startup": { + "medianMs": 616.43798828125, + "minMs": 604.083984375, + "maxMs": 638.344970703125 + }, + "idle": { + "visible": { + "cpuPercent": 0.7262130767852024, + "summedPhysicalFootprintMiB": 145.36273956298828 + }, + "hidden": { + "cpuPercent": 0.9041342688997197, + "summedPhysicalFootprintMiB": 145.80023956298828 + }, + "minimized": { + "cpuPercent": 1.097465997571969, + "summedPhysicalFootprintMiB": 149.14401245117188 + } + } + } +} diff --git a/docs/performance/tauri-experiments/recommendations.json b/docs/performance/tauri-experiments/recommendations.json new file mode 100644 index 0000000..f8e5c04 --- /dev/null +++ b/docs/performance/tauri-experiments/recommendations.json @@ -0,0 +1,723 @@ +[ + { + "changeId": "01", + "metric": { + "baseline": 3932.899995803833, + "after": 9.000000715255737, + "threshold": null, + "noiseBound": 150.4000096321106, + "direction": "lower", + "name": "total React render duration / 12 ticks", + "unit": "ms" + }, + "samples": { + "baseline": 3, + "after": 3 + }, + "comparison": { + "scenario": "playlist-playback", + "route": "/performance/", + "flow": "elapsed presentation", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "playlist-playback", + "lineage": { + "baseCommit": "8868b590b05efd5046367702f11acff9b95a1b05", + "headCommit": "c504db219711d1b25f7133f939325a4d70fc86c2" + } + }, + "artifacts": { + "scenario": "playlist-playback", + "baseline": "01-baseline-playlist.json", + "after": "01-after-playlist.json", + "baselineContext": { + "scenario": "playlist-playback", + "route": "/performance/", + "flow": "elapsed presentation", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "playlist-playback" + }, + "afterContext": { + "scenario": "playlist-playback", + "route": "/performance/", + "flow": "elapsed presentation", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "playlist-playback" + } + }, + "evidence": { + "kind": "browser-fixture", + "tool": "React Profiler / DOM and native-mock counters", + "artifact": "01-after-playlist.json", + "link": "01-after-playlist.json" + }, + "functionalArtifact": "validation.json", + "accessibilityArtifact": "validation.json", + "symptom": "elapsed presentation", + "hypothesis": "Reduce the measured work at its owning boundary; see the step report for the disconfirming result.", + "successCriterion": "Exploratory comparison; no user acceptance threshold was agreed. Native validation is required before a merge recommendation.", + "range": { + "baseline": [ + 3787.6999979019165, + 3938.100007534027 + ], + "after": [ + 8.800000429153442, + 9.900000095367432 + ] + }, + "decision": "UNVERIFIED", + "unresolvedAssumptions": [ + "No authenticated evidence registry is available.", + "Native WebKit/CPU/window/audio validation is blocked by the locked Mac." + ], + "nextAction": "Complete native comparison after unlock; assess each commit independently before any merge." + }, + { + "changeId": "02", + "metric": { + "baseline": 769.7000000476837, + "after": 32, + "threshold": null, + "noiseBound": 67.10000014305115, + "direction": "lower", + "name": "playlist open to two frames", + "unit": "ms" + }, + "samples": { + "baseline": 3, + "after": 3 + }, + "comparison": { + "scenario": "playlist-open", + "route": "/performance/", + "flow": "playlist windowing", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "playlist-open", + "lineage": { + "baseCommit": "22342c74f4779cdf124d0afae3a5393cbdfdf916", + "headCommit": "04ad86aff50363c70be44bfbf1d732ff7d16ccc7" + } + }, + "artifacts": { + "scenario": "playlist-open", + "baseline": "02-baseline-interactions.json", + "after": "02-after-interactions.json", + "baselineContext": { + "scenario": "playlist-open", + "route": "/performance/", + "flow": "playlist windowing", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "playlist-open" + }, + "afterContext": { + "scenario": "playlist-open", + "route": "/performance/", + "flow": "playlist windowing", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "playlist-open" + } + }, + "evidence": { + "kind": "browser-fixture", + "tool": "React Profiler / DOM and native-mock counters", + "artifact": "02-after-interactions.json", + "link": "02-after-interactions.json" + }, + "functionalArtifact": "validation.json", + "accessibilityArtifact": "validation.json", + "symptom": "playlist windowing", + "hypothesis": "Reduce the measured work at its owning boundary; see the step report for the disconfirming result.", + "successCriterion": "Exploratory comparison; no user acceptance threshold was agreed. Native validation is required before a merge recommendation.", + "range": { + "baseline": [ + 730.2999999523163, + 797.4000000953674 + ], + "after": [ + 32, + 50.40000009536743 + ] + }, + "decision": "UNVERIFIED", + "unresolvedAssumptions": [ + "No authenticated evidence registry is available.", + "Native WebKit/CPU/window/audio validation is blocked by the locked Mac." + ], + "nextAction": "Complete native comparison after unlock; assess each commit independently before any merge." + }, + { + "changeId": "03", + "metric": { + "baseline": 8.199999570846558, + "after": 0, + "threshold": null, + "noiseBound": 1.3000006675720215, + "direction": "lower", + "name": "hidden React render duration / 12 ticks", + "unit": "ms" + }, + "samples": { + "baseline": 3, + "after": 3 + }, + "comparison": { + "scenario": "simulated-hidden-playback", + "route": "/performance/", + "flow": "hidden presentation", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "simulated-hidden-playback", + "lineage": { + "baseCommit": "71467f37dfc0c1db221cda0dae453366669aadbf", + "headCommit": "ca9a4ee47f142d66fb9f05bfa3bdcb72650193e7" + } + }, + "artifacts": { + "scenario": "simulated-hidden-playback", + "baseline": "03-baseline-hidden.json", + "after": "03-after-hidden.json", + "baselineContext": { + "scenario": "simulated-hidden-playback", + "route": "/performance/", + "flow": "hidden presentation", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "simulated-hidden-playback" + }, + "afterContext": { + "scenario": "simulated-hidden-playback", + "route": "/performance/", + "flow": "hidden presentation", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "simulated-hidden-playback" + } + }, + "evidence": { + "kind": "browser-fixture", + "tool": "React Profiler / DOM and native-mock counters", + "artifact": "03-after-hidden.json", + "link": "03-after-hidden.json" + }, + "functionalArtifact": "validation.json", + "accessibilityArtifact": "validation.json", + "symptom": "hidden presentation", + "hypothesis": "Reduce the measured work at its owning boundary; see the step report for the disconfirming result.", + "successCriterion": "Exploratory comparison; no user acceptance threshold was agreed. Native validation is required before a merge recommendation.", + "range": { + "baseline": [ + 7.6999993324279785, + 9 + ], + "after": [ + 0, + 0 + ] + }, + "decision": "UNVERIFIED", + "unresolvedAssumptions": [ + "No authenticated evidence registry is available.", + "Native WebKit/CPU/window/audio validation is blocked by the locked Mac." + ], + "nextAction": "Complete native comparison after unlock; assess each commit independently before any merge." + }, + { + "changeId": "04a", + "metric": { + "baseline": 4.81194084, + "after": 0.045152910000000004, + "threshold": null, + "noiseBound": 0.17705958999999982, + "direction": "lower", + "name": "existing settings load", + "unit": "ms" + }, + "samples": { + "baseline": 3, + "after": 3 + }, + "comparison": { + "scenario": "startup-existing-settings", + "route": "native store benchmark", + "flow": "startup settings writes", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "not applicable", + "device": "macOS arm64 host", + "viewport": "not applicable", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm local filesystem", + "serviceWorker": "not applicable", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "startup-existing-settings", + "lineage": { + "baseCommit": "70c4a946d9801d66198a7bd53f9dd8d860e916ca", + "headCommit": "eb7fae6e0768cee454d0f0c37be244f4c40144c0" + } + }, + "artifacts": { + "scenario": "startup-existing-settings", + "baseline": "04a-baseline-settings.json", + "after": "04a-after-settings.json", + "baselineContext": { + "scenario": "startup-existing-settings", + "route": "native store benchmark", + "flow": "startup settings writes", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "not applicable", + "device": "macOS arm64 host", + "viewport": "not applicable", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm local filesystem", + "serviceWorker": "not applicable", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "startup-existing-settings" + }, + "afterContext": { + "scenario": "startup-existing-settings", + "route": "native store benchmark", + "flow": "startup settings writes", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "not applicable", + "device": "macOS arm64 host", + "viewport": "not applicable", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm local filesystem", + "serviceWorker": "not applicable", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "startup-existing-settings" + } + }, + "evidence": { + "kind": "native-store", + "tool": "release Rust ignored benchmark", + "artifact": "04a-after-settings.json", + "link": "04a-after-settings.json" + }, + "functionalArtifact": "validation.json", + "accessibilityArtifact": "validation.json", + "symptom": "startup settings writes", + "hypothesis": "Reduce the measured work at its owning boundary; see the step report for the disconfirming result.", + "successCriterion": "Exploratory comparison; no user acceptance threshold was agreed. Native validation is required before a merge recommendation.", + "range": { + "baseline": [ + 4.673603330000001, + 4.85066292 + ], + "after": [ + 0.04437209, + 0.053585 + ] + }, + "decision": "UNVERIFIED", + "unresolvedAssumptions": [ + "No authenticated evidence registry is available.", + "Native WebKit/CPU/window/audio validation is blocked by the locked Mac." + ], + "nextAction": "Complete native comparison after unlock; assess each commit independently before any merge." + }, + { + "changeId": "04b", + "metric": { + "baseline": 30.799999952316284, + "after": 31, + "threshold": null, + "noiseBound": 3.5999999046325684, + "direction": "lower", + "name": "main DOM ready", + "unit": "ms" + }, + "samples": { + "baseline": 3, + "after": 3 + }, + "comparison": { + "scenario": "main-startup", + "route": "/performance/startup.html", + "flow": "window entry loading", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "main-startup", + "lineage": { + "baseCommit": "67f09f62b127bd324d3a11b2d78fe00e307e7b60", + "headCommit": "48456a69aa02827aedf41e7bbb2319c4bacb4f68" + } + }, + "artifacts": { + "scenario": "main-startup", + "baseline": "04b-baseline-startup.json", + "after": "04b-after-startup.json", + "baselineContext": { + "scenario": "main-startup", + "route": "/performance/startup.html", + "flow": "window entry loading", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "main-startup" + }, + "afterContext": { + "scenario": "main-startup", + "route": "/performance/startup.html", + "flow": "window entry loading", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "main-startup" + } + }, + "evidence": { + "kind": "browser-fixture", + "tool": "React Profiler / DOM and native-mock counters", + "artifact": "04b-after-startup.json", + "link": "04b-after-startup.json" + }, + "functionalArtifact": "validation.json", + "accessibilityArtifact": "validation.json", + "symptom": "window entry loading", + "hypothesis": "Reduce the measured work at its owning boundary; see the step report for the disconfirming result.", + "successCriterion": "Exploratory comparison; no user acceptance threshold was agreed. Native validation is required before a merge recommendation.", + "range": { + "baseline": [ + 29.700000047683716, + 33.299999952316284 + ], + "after": [ + 30.799999952316284, + 31 + ] + }, + "decision": "UNVERIFIED", + "unresolvedAssumptions": [ + "No authenticated evidence registry is available.", + "Native WebKit/CPU/window/audio validation is blocked by the locked Mac." + ], + "nextAction": "Complete native comparison after unlock; assess each commit independently before any merge." + }, + { + "changeId": "04c", + "metric": { + "baseline": 10.448125, + "after": 9.463167, + "threshold": null, + "noiseBound": 5.375083999999999, + "direction": "lower", + "name": "startup files data ready", + "unit": "ms" + }, + "samples": { + "baseline": 3, + "after": 3 + }, + "comparison": { + "scenario": "startup-files", + "route": "native store benchmark", + "flow": "background startup files", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "not applicable", + "device": "macOS arm64 host", + "viewport": "not applicable", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm local filesystem", + "serviceWorker": "not applicable", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "startup-files", + "lineage": { + "baseCommit": "78c76f9cbfa5c4a0738dd889c3f06941e90243d2", + "headCommit": "78ad327b9cfdcd4b351405fd0f34180f930fe674" + } + }, + "artifacts": { + "scenario": "startup-files", + "baseline": "04c-baseline-files.json", + "after": "04c-after-files.json", + "baselineContext": { + "scenario": "startup-files", + "route": "native store benchmark", + "flow": "background startup files", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "not applicable", + "device": "macOS arm64 host", + "viewport": "not applicable", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm local filesystem", + "serviceWorker": "not applicable", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "startup-files" + }, + "afterContext": { + "scenario": "startup-files", + "route": "native store benchmark", + "flow": "background startup files", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "not applicable", + "device": "macOS arm64 host", + "viewport": "not applicable", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm local filesystem", + "serviceWorker": "not applicable", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "startup-files" + } + }, + "evidence": { + "kind": "native-store", + "tool": "release Rust ignored benchmark", + "artifact": "04c-after-files.json", + "link": "04c-after-files.json" + }, + "functionalArtifact": "validation.json", + "accessibilityArtifact": "validation.json", + "symptom": "background startup files", + "hypothesis": "Reduce the measured work at its owning boundary; see the step report for the disconfirming result.", + "successCriterion": "Exploratory comparison; no user acceptance threshold was agreed. Native validation is required before a merge recommendation.", + "range": { + "baseline": [ + 9.535375, + 14.910459 + ], + "after": [ + 8.349041, + 10.7255 + ] + }, + "decision": "UNVERIFIED", + "unresolvedAssumptions": [ + "No authenticated evidence registry is available.", + "Native WebKit/CPU/window/audio validation is blocked by the locked Mac." + ], + "nextAction": "Complete native comparison after unlock; assess each commit independently before any merge." + }, + { + "changeId": "05", + "metric": { + "baseline": 15850986, + "after": 4283276, + "threshold": null, + "noiseBound": 0, + "direction": "lower", + "name": "queue response bytes / burst", + "unit": "bytes" + }, + "samples": { + "baseline": 3, + "after": 3 + }, + "comparison": { + "scenario": "importer-refresh-burst", + "route": "/performance/importer.html?window=lastfm-importer&queueFixture=1", + "flow": "superseded importer refresh", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "importer-refresh-burst", + "lineage": { + "baseCommit": "0c09cf86d19980c16936e506e4fdc7e71ddd3299", + "headCommit": "fea5335577b5a2b78de98834fcec32fb8dcf5038" + } + }, + "artifacts": { + "scenario": "importer-refresh-burst", + "baseline": "05-baseline-refresh.json", + "after": "05-after-refresh.json", + "baselineContext": { + "scenario": "importer-refresh-burst", + "route": "/performance/importer.html?window=lastfm-importer&queueFixture=1", + "flow": "superseded importer refresh", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "importer-refresh-burst" + }, + "afterContext": { + "scenario": "importer-refresh-burst", + "route": "/performance/importer.html?window=lastfm-importer&queueFixture=1", + "flow": "superseded importer refresh", + "dataset": "generated 4000 tracks / 4100 playlist entries; importer burst uses 8000 batches; see README for each fixture", + "browser": "same in-app Chromium session; exact version not captured", + "device": "macOS arm64 host", + "viewport": "1280x720", + "networkThrottle": "none added; importer mock delay 15ms/page", + "cpuThrottle": "none added", + "cache": "warm loaded fixture", + "serviceWorker": "none added by fixture; host-wide state not captured", + "extensions": "not captured", + "account": "generated signed-out fixture", + "workspace": "codex/tauri-performance-experiments", + "test": "importer-refresh-burst" + } + }, + "evidence": { + "kind": "browser-fixture", + "tool": "React Profiler / DOM and native-mock counters", + "artifact": "05-after-refresh.json", + "link": "05-after-refresh.json" + }, + "functionalArtifact": "validation.json", + "accessibilityArtifact": "validation.json", + "symptom": "superseded importer refresh", + "hypothesis": "Reduce the measured work at its owning boundary; see the step report for the disconfirming result.", + "successCriterion": "Exploratory comparison; no user acceptance threshold was agreed. Native validation is required before a merge recommendation.", + "range": { + "baseline": [ + 15850986, + 15850986 + ], + "after": [ + 4283276, + 4283276 + ] + }, + "decision": "UNVERIFIED", + "unresolvedAssumptions": [ + "No authenticated evidence registry is available.", + "Native WebKit/CPU/window/audio validation is blocked by the locked Mac." + ], + "nextAction": "Complete native comparison after unlock; assess each commit independently before any merge." + } +] diff --git a/docs/performance/tauri-experiments/structural-validation.json b/docs/performance/tauri-experiments/structural-validation.json new file mode 100644 index 0000000..cba8c81 --- /dev/null +++ b/docs/performance/tauri-experiments/structural-validation.json @@ -0,0 +1,69 @@ +{ + "tool": "/Users/rianjs/.codex/plugins/cache/monit-plugins/frontend/2.2.1/codex-skills/react-performance/scripts/validate-recommendation.mjs", + "registry": "Unavailable; no trusted registry or acceptance threshold was invented. The CLI was invoked with each report and without a registry.", + "results": [ + { + "changeId": "01", + "valid": false, + "decision": "UNVERIFIED", + "diagnostics": [ + "registry must be an object", + "structural validation cannot authenticate evidence or derive a decision" + ] + }, + { + "changeId": "02", + "valid": false, + "decision": "UNVERIFIED", + "diagnostics": [ + "registry must be an object", + "structural validation cannot authenticate evidence or derive a decision" + ] + }, + { + "changeId": "03", + "valid": false, + "decision": "UNVERIFIED", + "diagnostics": [ + "registry must be an object", + "structural validation cannot authenticate evidence or derive a decision" + ] + }, + { + "changeId": "04a", + "valid": false, + "decision": "UNVERIFIED", + "diagnostics": [ + "registry must be an object", + "structural validation cannot authenticate evidence or derive a decision" + ] + }, + { + "changeId": "04b", + "valid": false, + "decision": "UNVERIFIED", + "diagnostics": [ + "registry must be an object", + "structural validation cannot authenticate evidence or derive a decision" + ] + }, + { + "changeId": "04c", + "valid": false, + "decision": "UNVERIFIED", + "diagnostics": [ + "registry must be an object", + "structural validation cannot authenticate evidence or derive a decision" + ] + }, + { + "changeId": "05", + "valid": false, + "decision": "UNVERIFIED", + "diagnostics": [ + "registry must be an object", + "structural validation cannot authenticate evidence or derive a decision" + ] + } + ] +} diff --git a/docs/performance/tauri-experiments/validation.json b/docs/performance/tauri-experiments/validation.json new file mode 100644 index 0000000..ced7423 --- /dev/null +++ b/docs/performance/tauri-experiments/validation.json @@ -0,0 +1,40 @@ +{ + "codeRevision": "fea5335577b5a2b78de98834fcec32fb8dcf5038", + "fixtureRevision": "948e1b44df3b50f703e968dd54a31d954bb86f22", + "scope": "Combined experiment branch; individual step checks and exact measurements are in README.md and adjacent raw files. These are recorded local observations, not authenticated evidence-registry records.", + "automated": { + "cargo fmt --all --check": "PASS", + "cargo clippy --workspace --all-targets -- -D warnings": "PASS", + "cargo test --workspace": "PASS", + "startup release tests including manual file/settings benchmarks": "PASS", + "npm test": "PASS: 96 Node checks; 76 Vitest tests; 8 existing manual benchmarks skipped", + "npm run lint": "PASS", + "npm run build": "PASS", + "node scripts/check-tauri-acl.mjs": "PASS: 90 commands", + "node scripts/check-docs.mjs": "PASS: 19 files", + "node scripts/check-release.mjs": "PASS", + "npm exec tauri -- permission list": "PASS", + "isolated release app bundle with dev-token-store": "PASS: native-candidate.json" + }, + "browser": { + "globalImporterFilter": "PASS: Batch 8000 found beyond first response page", + "globalImporterSelection": "PASS: select last batch, clear filter, select all -> Combine selected (8000)", + "darkPlaylist": "PASS: visual check at 1280x720 and 760x600", + "narrowPlaylistKeyboard": "PASS: verified innerWidth=760, innerHeight=600; End focuses Track 99 at aria-posinset=4100, aria-setsize=4100", + "viewportCleanup": "PASS: explicit override reset" + }, + "native": { + "status": "PASS for measured local scope", + "evidence": [ + "native-manifest.json", + "native-summary.json", + "native-before.json", + "native-after.json", + "native-after-marquee.json" + ], + "scope": "Combined before/final release builds on macOS; three native interaction and warm-startup samples, three consecutive paused CPU/footprint samples per visible/hidden/minimized state. Local silent playback, visibility/animation and normal shutdown checked.", + "outcome": "Large native playlist responsiveness gain. No native startup or idle CPU gain. Lower post-workload footprint snapshots are observational, not independent memory trials.", + "limitations": "No physical media keys, deliberately stalled native startup/exit race, OAuth, account-bound importer IPC, cold storage, other operating systems, or direct hardware scanout timing tested." + }, + "formalRecommendation": "UNVERIFIED: no authenticated registry or agreed acceptance threshold; structural-validation.json records the CLI result." +} diff --git a/docs/tauri.md b/docs/tauri.md index 8754da0..7c5cbfe 100644 --- a/docs/tauri.md +++ b/docs/tauri.md @@ -98,8 +98,13 @@ owns queue advancement and visible playback state. ## Runtime and lifecycle -Startup creates the stores and performs crash recovery before publishing -authoritative in-memory state. Long hydration and follow-up connection work is +Startup performs crash recovery and reads the overlay, membership, playlists, +settings, and cooldown on an owned blocking task before publishing authoritative +in-memory state. Setup returns while that task runs; the application command +dispatcher waits for its success or rejects calls with the startup failure. +Native menu/media composition remains on the main thread. Early native actions +are ignored until state exists, and exit waits for startup recovery to complete +before the normal drain. Long hydration and follow-up connection work is owned by spawned tasks; startup notices are published through the main channel. Persistent mutations use their owning gate and detached completion so caller cancellation cannot leave durable state newer than an authoritative @@ -132,6 +137,11 @@ form destinations; permits scripts from self; permits artwork from self, `https://i.scdn.co`, and data URLs; and limits connections to Tauri IPC. Adding a remote origin requires an explicit architecture review. +The frontend entry loads only the component for its native window label. Main +and importer view modules are separate chunks; shared theme/dialog CSS remains +eager. A loading status is replaced by the chosen view, and a chunk-load error +offers a full-window retry without changing the webview's authority. + Vite binds `127.0.0.1` on strict port `5173` and chooses the frontend target appropriate to the native webview floor. Tauri's development URL must remain identical. Platform overlays define the supported bundle contract: