From 789715a29239d17fa89f2c4e30a21319aee5e25c Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:19:20 -0500 Subject: [PATCH 1/4] feat(queue): backend queue state machine replacing frontend shuffle, navigation, and play-next logic Move ~500 lines of queue state machine from JS frontend to Rust backend. Frontend queue store becomes a thin reactive layer calling backend commands and applying returned state snapshots. Backend: - Extend queue_state table with play_next_offset, play_history_json, play_next_track_ids_json, repeat_one_pending columns - Add toggle_shuffle (Fisher-Yates with play-next pinning), add_play_next (move semantics + offset tracking), advance_to_next/previous (repeat-one two-phase, loop modes, history), skip_to_next/previous (override repeat-one), check_integrity - Register 6 new Tauri commands, enhance queue_set_shuffle to return QueueStateSnapshot - 773 Rust tests pass Frontend: - Remove _shuffleItems, _reshuffleForLoopRestart, _originalOrder, _playHistory, _playNextTrackIds, _playNextOffset, _repeatOnePending, _syncQueueToBackend, _validateQueueIntegrity - Add _applySnapshot and _applyNavigationResult - Update queue-builder and queue.props tests for backend-delegated pattern - 444 Vitest tests pass Closes TASK-328 Co-Authored-By: Claude Opus 4.6 --- app/frontend/__tests__/queue-builder.test.js | 33 +- app/frontend/__tests__/queue.props.test.js | 292 ++-- app/frontend/js/api/queue.js | 101 +- app/frontend/js/components/library-browser.js | 7 - app/frontend/js/events.js | 116 +- .../js/mixins/context-menu-actions.js | 179 ++- app/frontend/js/stores/library.js | 135 +- app/frontend/js/stores/queue.js | 465 ++---- app/frontend/js/utils/library-operations.js | 5 +- app/frontend/js/utils/queue-builder.js | 5 +- ...-shuffle-navigation-and-play-next-logic.md | 49 +- .../mt-tauri/gen/schemas/acl-manifests.json | 2 +- .../mt-tauri/gen/schemas/desktop-schema.json | 162 --- crates/mt-tauri/gen/schemas/macOS-schema.json | 162 --- crates/mt-tauri/src/commands/mod.rs | 7 +- crates/mt-tauri/src/commands/queue.rs | 284 +++- crates/mt-tauri/src/db/models.rs | 4 + crates/mt-tauri/src/db/queue.rs | 1273 ++++++++++++++++- crates/mt-tauri/src/db/schema.rs | 35 + crates/mt-tauri/src/lib.rs | 15 +- 20 files changed, 2152 insertions(+), 1179 deletions(-) rename backlog/{tasks => completed}/task-328 - Backend-queue-state-machine-replacing-frontend-shuffle-navigation-and-play-next-logic.md (76%) diff --git a/app/frontend/__tests__/queue-builder.test.js b/app/frontend/__tests__/queue-builder.test.js index d764387c..68ea7d2a 100644 --- a/app/frontend/__tests__/queue-builder.test.js +++ b/app/frontend/__tests__/queue-builder.test.js @@ -46,11 +46,6 @@ function createMockCtx(queueOverrides = {}, playerOverrides = {}) { currentIndex: -1, shuffle: false, _updating: false, - _originalOrder: [], - _playHistory: [], - _playNextOffset: 0, - _playNextTrackIds: new Set(), - _pushToHistory: vi.fn(), ...queueOverrides, }, player: { @@ -131,37 +126,17 @@ describe('handleDoubleClickPlay', () => { expect(ctx.queue.currentIndex).toBe(1); }); - it('resets queue state fields after play context', async () => { + it('applies shuffle_enabled from result', async () => { const tracks = makeTracks(['A', 'B']); const result = makePlayContextResult(tracks, 0); + result.shuffle_enabled = true; queueApi.playContext.mockResolvedValue(result); - const existingHistory = [{ id: 99, title: 'Old' }]; - const ctx = createMockCtx({ - _playHistory: existingHistory, - _playNextOffset: 5, - _playNextTrackIds: new Set([99]), - }); - - await handleDoubleClickPlay(ctx, tracks[0], tracks, 0, 'test'); - - expect(ctx.queue._playHistory).toEqual([]); - expect(ctx.queue._playNextOffset).toBe(0); - expect(ctx.queue._playNextTrackIds.size).toBe(0); - }); - - it('sets _originalOrder to match items', async () => { - const tracks = makeTracks(['A', 'B', 'C']); - const result = makePlayContextResult(tracks, 0); - queueApi.playContext.mockResolvedValue(result); - - const ctx = createMockCtx(); + const ctx = createMockCtx({ shuffle: false }); await handleDoubleClickPlay(ctx, tracks[0], tracks, 0, 'test'); - expect(ctx.queue._originalOrder).toEqual(tracks); - // Should be a copy, not the same reference - expect(ctx.queue._originalOrder).not.toBe(ctx.queue.items); + expect(ctx.queue.shuffle).toBe(true); }); it('calls updateTrackState with track and duration_ms', async () => { diff --git a/app/frontend/__tests__/queue.props.test.js b/app/frontend/__tests__/queue.props.test.js index 419e8a83..ef289943 100644 --- a/app/frontend/__tests__/queue.props.test.js +++ b/app/frontend/__tests__/queue.props.test.js @@ -31,9 +31,17 @@ vi.mock('../js/api/queue.js', () => ({ setShuffle: vi.fn().mockResolvedValue({}), setLoop: vi.fn().mockResolvedValue({}), setCurrentIndex: vi.fn().mockResolvedValue({}), + addPlayNext: vi.fn().mockResolvedValue({}), + playNextTrack: vi.fn().mockResolvedValue({}), + playPreviousTrack: vi.fn().mockResolvedValue({}), + skipNext: vi.fn().mockResolvedValue({}), + skipPrevious: vi.fn().mockResolvedValue({}), + checkIntegrity: vi.fn().mockResolvedValue({}), }, })); +import { queue as queueApi } from '../js/api/queue.js'; + // Mock Tauri global.window = { __TAURI__: undefined, @@ -71,178 +79,73 @@ describe('Queue Store - Property-Based Tests', () => { store = Alpine.store('queue'); }); - describe('Shuffle Invariants', () => { - test.prop([trackListArbitrary])( - 'shuffle preserves all tracks (current track stored separately)', - async (tracks) => { - // Need at least 2 tracks to shuffle - fc.pre(tracks.length >= 2); - - // Setup - store.items = [...tracks]; - store._originalOrder = [...tracks]; - store.currentIndex = 0; - - // Get original track IDs - const originalIds = new Set(tracks.map((t) => t.id)); - - // Shuffle - store._shuffleItems(); - - // Verify same tracks are preserved (all tracks still in queue) - const shuffledIds = new Set(store.items.map((t) => t.id)); - expect(shuffledIds).toEqual(originalIds); - - // Queue should have same length (current track stays at index 0) - expect(store.items.length).toBe(tracks.length); - }, - ); - + describe('Shuffle (Backend-Delegated)', () => { test.prop([trackListArbitrary])( - 'shuffle twice produces different order (probabilistic)', + 'toggleShuffle calls setShuffle with inverted state', async (tracks) => { - // Skip if too few tracks to shuffle meaningfully - fc.pre(tracks.length >= 3); + fc.pre(tracks.length >= 1); store.items = [...tracks]; - store._originalOrder = [...tracks]; store.currentIndex = 0; + store.shuffle = false; - // First shuffle - current track at index 0 - store._shuffleItems(); - const firstShuffle = store.items.map((t) => t.id); - - // Queue should have same length after shuffle - expect(store.items.length).toBe(tracks.length); + // Mock backend returning a snapshot + const snapshot = { + items: tracks.map((t) => ({ track: t })), + current_index: 0, + shuffle_enabled: true, + loop_mode: 'none', + play_next_offset: 0, + }; + queueApi.setShuffle.mockResolvedValue(snapshot); - // Second shuffle (simulating re-shuffle at end of queue with loop) - store._shuffleItems(); - const secondShuffle = store.items.map((t) => t.id); + await store.toggleShuffle(); - // With 3+ tracks, probability of identical shuffle is low - // (we accept some false negatives for simplicity) - // Both shuffles should maintain all tracks - expect(store.items.length).toBe(tracks.length); + expect(queueApi.setShuffle).toHaveBeenCalledWith(true); }, ); test.prop([trackListArbitrary])( - 'shuffle keeps current track at index 0 (task-213)', + 'toggleShuffle applies snapshot from backend', async (tracks) => { fc.pre(tracks.length >= 2); store.items = [...tracks]; - store._originalOrder = [...tracks]; - const currentIdx = Math.floor(tracks.length / 2); - store.currentIndex = currentIdx; - const currentTrack = tracks[currentIdx]; - - store._shuffleItems(); - - // Current track should be at index 0 - expect(store.items[0].id).toBe(currentTrack.id); - - // Queue should have same length (no tracks removed) - expect(store.items.length).toBe(tracks.length); - - // currentIndex should be 0 - expect(store.currentIndex).toBe(0); - }, - ); - - it('shuffle with duplicate tracks handles current track correctly (task-213)', () => { - // Create queue with duplicate tracks: [A, B, A, C] - // Playing track at index 2 (second occurrence of A) - const trackA1 = { id: 1, title: 'Track A', artist: 'Artist', album: 'Album' }; - const trackB = { id: 2, title: 'Track B', artist: 'Artist', album: 'Album' }; - const trackA2 = { id: 1, title: 'Track A', artist: 'Artist', album: 'Album' }; // Same ID, different object - const trackC = { id: 3, title: 'Track C', artist: 'Artist', album: 'Album' }; - - store.items = [trackA1, trackB, trackA2, trackC]; - store._originalOrder = [trackA1, trackB, trackA2, trackC]; - store.currentIndex = 2; // Playing second occurrence of A (trackA2) - - // Shuffle the queue - store._shuffleItems(); - - // Queue should have 4 items (current track stays at index 0) - expect(store.items.length).toBe(4); - - // Current track (second A) should be at index 0 - expect(store.items[0]).toBe(trackA2); - expect(store.currentIndex).toBe(0); - - // Both occurrences of A should still be in the queue - // since we filter by index, not by ID - const trackACount = store.items.filter((t) => t.id === 1).length; - expect(trackACount).toBe(2); - - // Verify queue has all 4 tracks (IDs 1, 2, 1, 3 sorted as 1, 1, 2, 3) - const trackIds = store.items.map((t) => t.id).sort(); - expect(trackIds).toEqual([1, 1, 2, 3]); - }); - - test.prop([trackListArbitrary])( - 'toggle shuffle twice returns to original order', - async (tracks) => { - fc.pre(tracks.length >= 1); - - store.items = [...tracks]; - store._originalOrder = [...tracks]; - store.currentIndex = tracks.length > 0 ? 0 : -1; - const originalOrder = tracks.map((t) => t.id); - - // Shuffle on + store.currentIndex = 0; store.shuffle = false; - await store.toggleShuffle(); - // Shuffle off (should restore) + // Simulate backend returning shuffled order + const shuffled = [...tracks].reverse(); + const snapshot = { + items: shuffled.map((t) => ({ track: t })), + current_index: 0, + shuffle_enabled: true, + loop_mode: 'none', + play_next_offset: 0, + }; + queueApi.setShuffle.mockResolvedValue(snapshot); + await store.toggleShuffle(); - const restoredOrder = store.items.map((t) => t.id); - expect(restoredOrder).toEqual(originalOrder); + expect(store.shuffle).toBe(true); + expect(store.items.map((t) => t.id)).toEqual(shuffled.map((t) => t.id)); + expect(store.currentIndex).toBe(0); }, ); - test.prop([trackListArbitrary])( - '_reshuffleForLoopRestart does not put just-played track first (task-222)', - async (tracks) => { - // Need at least 2 tracks for reshuffle to be meaningful - fc.pre(tracks.length >= 2); - - store.items = [...tracks]; - store._originalOrder = [...tracks]; - // Set current index to last track (simulating end of queue) - store.currentIndex = tracks.length - 1; - const justPlayedTrack = tracks[tracks.length - 1]; - - // Reshuffle for loop restart - store._reshuffleForLoopRestart(); - - // Just-played track should NOT be at index 0 - expect(store.items[0].id).not.toBe(justPlayedTrack.id); + it('toggleShuffle handles API error gracefully', async () => { + store.items = [{ id: 1, title: 'A' }]; + store.shuffle = false; - // Just-played track should be at the END - expect(store.items[store.items.length - 1].id).toBe(justPlayedTrack.id); - - // All tracks should still be preserved - expect(store.items.length).toBe(tracks.length); - - // Original order should be updated to new shuffle - expect(store._originalOrder.length).toBe(tracks.length); - }, - ); + queueApi.setShuffle.mockRejectedValue(new Error('IPC failed')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - it('_reshuffleForLoopRestart with single track does nothing', () => { - const trackA = { id: 1, title: 'Track A', artist: 'Artist', album: 'Album' }; - store.items = [trackA]; - store.currentIndex = 0; + await store.toggleShuffle(); - store._reshuffleForLoopRestart(); + // Shuffle state should not change on error + expect(store.shuffle).toBe(false); - // Single track - nothing should change - expect(store.items.length).toBe(1); - expect(store.items[0].id).toBe(1); + consoleSpy.mockRestore(); }); }); @@ -565,88 +468,60 @@ describe('Queue Store - Property-Based Tests', () => { }); }); - describe('Play Next Invariants', () => { + describe('Play Next (Backend-Delegated)', () => { test.prop([ trackListArbitrary, fc.array(trackArbitrary, { minLength: 1, maxLength: 5 }), - fc.nat(), ])( - 'playNextTracks inserts after currentIndex', - async (tracks, playNextTracks, rawIdx) => { - fc.pre(tracks.length >= 2); + 'playNextTracks calls addPlayNext with track IDs', + async (tracks, playNextTracks) => { + fc.pre(tracks.length >= 1); store.items = [...tracks]; - store._originalOrder = [...tracks]; - const currentIdx = rawIdx % tracks.length; - store.currentIndex = currentIdx; - store._playNextOffset = 0; + store.currentIndex = 0; - const originalLength = store.items.length; - const currentTrackId = store.items[currentIdx].id; - - // Simulate the store's move-then-insert logic to compute expected length: - // For each input track, if it exists in the queue (and isn't current), - // it's removed first. The track is always added to tracksToInsert - // (including duplicates in input). Current track is skipped entirely. - const simQueue = new Set(tracks.map((t) => t.id)); - let removals = 0; - let inserts = 0; - for (const t of playNextTracks) { - if (t.id === currentTrackId) continue; - if (simQueue.has(t.id)) { - simQueue.delete(t.id); - removals++; - } - inserts++; - } - const expectedLength = originalLength - removals + inserts; + // Mock backend returning a snapshot with all tracks + const allTracks = [...tracks, ...playNextTracks]; + const snapshot = { + items: allTracks.map((t) => ({ track: t })), + current_index: 0, + shuffle_enabled: false, + loop_mode: 'none', + play_next_offset: playNextTracks.length, + }; + queueApi.addPlayNext.mockResolvedValue(snapshot); await store.playNextTracks(playNextTracks); - // Invariant: current track is still in the queue - expect(store.items.find((t) => t.id === currentTrackId)).toBeTruthy(); - - // Invariant: total queue length matches simulated move-then-insert - expect(store.items.length).toBe(expectedLength); + expect(queueApi.addPlayNext).toHaveBeenCalledWith( + playNextTracks.map((t) => t.id), + ); }, ); test.prop([trackListArbitrary, fc.array(trackArbitrary, { minLength: 1, maxLength: 5 })])( - 'playNextTracks preserves all tracks', + 'playNextTracks applies snapshot from backend', async (tracks, playNextTracks) => { fc.pre(tracks.length >= 1); store.items = [...tracks]; - store._originalOrder = [...tracks]; store.currentIndex = 0; - store._playNextOffset = 0; - - const currentTrackId = tracks[0].id; - - // Simulate the store's move-then-insert logic - const simQueue = new Set(tracks.map((t) => t.id)); - let removals = 0; - let inserts = 0; - for (const t of playNextTracks) { - if (t.id === currentTrackId) continue; - if (simQueue.has(t.id)) { - simQueue.delete(t.id); - removals++; - } - inserts++; - } - const expectedLength = tracks.length - removals + inserts; - await store.playNextTracks(playNextTracks); + const resultTracks = [...tracks, ...playNextTracks]; + const snapshot = { + items: resultTracks.map((t) => ({ track: t })), + current_index: 0, + shuffle_enabled: false, + loop_mode: 'none', + play_next_offset: playNextTracks.length, + }; + queueApi.addPlayNext.mockResolvedValue(snapshot); - // Invariant: all play-next tracks present - const resultIds = store.items.map((t) => t.id); - for (const t of playNextTracks) { - expect(resultIds).toContain(t.id); - } + await store.playNextTracks(playNextTracks); - // Invariant: total count matches simulated move-then-insert - expect(store.items.length).toBe(expectedLength); + // Items should be set from snapshot + expect(store.items.length).toBe(resultTracks.length); + expect(store.currentIndex).toBe(0); }, ); }); @@ -660,9 +535,6 @@ describe('Queue Store - Property-Based Tests', () => { await expect(store.clear()).resolves.not.toThrow(); await expect(store.remove(0)).resolves.not.toThrow(); await expect(store.reorder(0, 1)).resolves.not.toThrow(); - - store._shuffleItems(); - expect(store.items.length).toBe(0); }); test.prop([fc.nat()])('operations with out-of-bounds indices are safe', async (idx) => { @@ -678,10 +550,6 @@ describe('Queue Store - Property-Based Tests', () => { store.items = [track]; store.currentIndex = 0; - // Shuffle should not change anything - store._shuffleItems(); - expect(store.items[0].id).toBe(track.id); - // Remove should empty queue await store.remove(0); expect(store.items.length).toBe(0); diff --git a/app/frontend/js/api/queue.js b/app/frontend/js/api/queue.js index 5869bef2..71bc751d 100644 --- a/app/frontend/js/api/queue.js +++ b/app/frontend/js/api/queue.js @@ -225,7 +225,7 @@ export const queue = { /** * Set shuffle enabled in queue (uses Tauri command) * @param {boolean} enabled - Whether shuffle is enabled - * @returns {Promise} + * @returns {Promise} State snapshot with reordered queue */ async setShuffle(enabled) { if (invoke) { @@ -255,4 +255,103 @@ export const queue = { } console.debug('Queue setLoop (no-op in browser):', mode); }, + + /** + * Add tracks as "play next" with backend-managed offset and move semantics + * @param {number[]} trackIds - Track IDs to play next + * @returns {Promise} + */ + async addPlayNext(trackIds) { + if (invoke) { + try { + return await invoke('queue_add_play_next', { trackIds }); + } catch (error) { + console.error('[api.queue.addPlayNext] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + throw new ApiError(500, 'addPlayNext not available in browser mode'); + }, + + /** + * Play next track with full state machine (repeat-one, loop, history, audio) + * @returns {Promise} + */ + async playNextTrack() { + if (invoke) { + try { + return await invoke('queue_play_next_track'); + } catch (error) { + console.error('[api.queue.playNextTrack] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + throw new ApiError(500, 'playNextTrack not available in browser mode'); + }, + + /** + * Play previous track with full state machine (>3sec restart, history, loop wrap) + * @param {number} currentTimeMs - Current playback position in milliseconds + * @returns {Promise} + */ + async playPreviousTrack(currentTimeMs) { + if (invoke) { + try { + return await invoke('queue_play_previous_track', { currentTimeMs }); + } catch (error) { + console.error('[api.queue.playPreviousTrack] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + throw new ApiError(500, 'playPreviousTrack not available in browser mode'); + }, + + /** + * Skip to next track, overriding repeat-one mode + * @returns {Promise} + */ + async skipNext() { + if (invoke) { + try { + return await invoke('queue_skip_next'); + } catch (error) { + console.error('[api.queue.skipNext] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + throw new ApiError(500, 'skipNext not available in browser mode'); + }, + + /** + * Skip to previous track, overriding repeat-one mode + * @param {number} currentTimeMs - Current playback position in milliseconds + * @returns {Promise} + */ + async skipPrevious(currentTimeMs) { + if (invoke) { + try { + return await invoke('queue_skip_previous', { currentTimeMs }); + } catch (error) { + console.error('[api.queue.skipPrevious] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + throw new ApiError(500, 'skipPrevious not available in browser mode'); + }, + + /** + * Run queue integrity check and auto-repair + * @returns {Promise} + */ + async checkIntegrity() { + if (invoke) { + try { + return await invoke('queue_check_integrity'); + } catch (error) { + console.error('[api.queue.checkIntegrity] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + throw new ApiError(500, 'checkIntegrity not available in browser mode'); + }, }; diff --git a/app/frontend/js/components/library-browser.js b/app/frontend/js/components/library-browser.js index be27c667..c8d249e3 100644 --- a/app/frontend/js/components/library-browser.js +++ b/app/frontend/js/components/library-browser.js @@ -456,13 +456,6 @@ export function createLibraryBrowser(Alpine) { this.library.filteredTracks, index, 'library-browser', - { - beforePlay: () => { - if (this.queue.currentIndex >= 0) { - this.queue._pushToHistory(this.queue.currentIndex); - } - }, - }, ); }, diff --git a/app/frontend/js/events.js b/app/frontend/js/events.js index 72263979..ff19dc2f 100644 --- a/app/frontend/js/events.js +++ b/app/frontend/js/events.js @@ -8,7 +8,7 @@ * Event naming convention: `domain:action` (e.g., `library:updated`) */ -import { removeFromQueue } from "./utils/library-operations.js"; +import { removeFromQueue } from './utils/library-operations.js'; const { listen } = window.__TAURI__?.event ?? { listen: () => Promise.resolve(() => {}), @@ -22,24 +22,24 @@ const listeners = []; */ export const Events = { // Library events - LIBRARY_UPDATED: "library:updated", - LIBRARY_RECONCILE: "library:reconcile", - SCAN_PROGRESS: "library:scan-progress", - SCAN_COMPLETE: "library:scan-complete", + LIBRARY_UPDATED: 'library:updated', + LIBRARY_RECONCILE: 'library:reconcile', + SCAN_PROGRESS: 'library:scan-progress', + SCAN_COMPLETE: 'library:scan-complete', // Queue events - QUEUE_UPDATED: "queue:updated", - QUEUE_STATE_CHANGED: "queue:state-changed", + QUEUE_UPDATED: 'queue:updated', + QUEUE_STATE_CHANGED: 'queue:state-changed', // Favorites events - FAVORITES_UPDATED: "favorites:updated", + FAVORITES_UPDATED: 'favorites:updated', // Playlist events - PLAYLISTS_UPDATED: "playlists:updated", + PLAYLISTS_UPDATED: 'playlists:updated', // Settings events (Tauri Store) - SETTINGS_CHANGED: "settings://changed", - SETTINGS_RESET: "settings://reset", + SETTINGS_CHANGED: 'settings://changed', + SETTINGS_RESET: 'settings://reset', }; /** @@ -71,15 +71,15 @@ function createLibraryUpdatedHandler(Alpine) { return (payload) => { const { action, track_ids } = payload; - const library = Alpine.store("library"); + const library = Alpine.store('library'); console.log( `[events] Library ${action}:`, - track_ids.length ? `${track_ids.length} tracks` : "bulk update", + track_ids.length ? `${track_ids.length} tracks` : 'bulk update', ); // Deletions are handled by library:reconcile event with authoritative stats - if (action === "added" || action === "modified") { + if (action === 'added' || action === 'modified') { debouncedFetchTracks(library); } }; @@ -87,7 +87,7 @@ function createLibraryUpdatedHandler(Alpine) { function handleScanProgress(Alpine, payload) { const { job_id, status, scanned, found, errors, current_path } = payload; - const library = Alpine.store("library"); + const library = Alpine.store('library'); if (library.setScanProgress) { library.setScanProgress({ @@ -103,7 +103,7 @@ function handleScanProgress(Alpine, payload) { function handleScanComplete(Alpine, payload) { const { added, skipped, errors, duration_ms } = payload; - const library = Alpine.store("library"); + const library = Alpine.store('library'); console.log( `[events] Scan complete: ${added} added, ${skipped} skipped, ${errors} errors (${duration_ms}ms)`, @@ -119,13 +119,13 @@ function createQueueUpdatedHandler(Alpine) { let queueReloadDebounce = null; return (payload) => { - console.log("[events] queue:updated", payload); + console.log('[events] queue:updated', payload); - const queue = Alpine.store("queue"); + const queue = Alpine.store('queue'); if (queue?._initializing || queue?._updating) { console.log( - "[events] Skipping queue reload during", - queue._initializing ? "initialization" : "active update", + '[events] Skipping queue reload during', + queue._initializing ? 'initialization' : 'active update', ); return; } @@ -135,7 +135,7 @@ function createQueueUpdatedHandler(Alpine) { } queueReloadDebounce = setTimeout(() => { - const queue = Alpine.store("queue"); + const queue = Alpine.store('queue'); if (queue && queue.load && !queue._initializing && !queue._updating) { queue.load(); } @@ -144,17 +144,17 @@ function createQueueUpdatedHandler(Alpine) { } function handleQueueStateChanged(Alpine, payload) { - console.log("[events] queue:state-changed", payload); + console.log('[events] queue:state-changed', payload); - const queue = Alpine.store("queue"); + const queue = Alpine.store('queue'); if (queue && !queue._initializing && !queue._updating) { queue.currentIndex = payload.current_index; queue.shuffle = payload.shuffle_enabled; queue.loop = payload.loop_mode; } else if (queue?._initializing || queue?._updating) { console.log( - "[events] Skipping queue state update during", - queue._initializing ? "initialization" : "active update", + '[events] Skipping queue state update during', + queue._initializing ? 'initialization' : 'active update', ); } } @@ -168,7 +168,7 @@ function handleLibraryReconcile(Alpine, payload) { total_duration, revision, } = payload; - const library = Alpine.store("library"); + const library = Alpine.store('library'); console.log( `[events] Library reconcile (${mutation}): total=${total_tracks}, removed=${removed_ids.length}`, @@ -182,7 +182,7 @@ function handleLibraryReconcile(Alpine, payload) { library._lastRevision = revision; if ( - (mutation === "delete" || mutation === "dedup") && + (mutation === 'delete' || mutation === 'dedup') && removed_ids.length > 0 ) { // Targeted removal: filter IDs from local view, queue cleanup @@ -190,9 +190,9 @@ function handleLibraryReconcile(Alpine, payload) { library._removeFromView(idSet); removeFromQueue(Alpine, idSet); } else if ( - mutation === "scan_complete" || - mutation === "delete" || - mutation === "dedup" + mutation === 'scan_complete' || + mutation === 'delete' || + mutation === 'dedup' ) { // Bulk change: full refetch library.fetchTracks(); @@ -200,8 +200,8 @@ function handleLibraryReconcile(Alpine, payload) { // Refresh if currently viewing an affected section if ( - affected_sections.includes("liked") && - library.currentSection === "liked" + affected_sections.includes('liked') && + library.currentSection === 'liked' ) { library.fetchTracks(); } @@ -209,8 +209,8 @@ function handleLibraryReconcile(Alpine, payload) { function handleFavoritesUpdated(Alpine, payload) { const { action, track_id } = payload; - const library = Alpine.store("library"); - const player = Alpine.store("player"); + const library = Alpine.store('library'); + const player = Alpine.store('player'); console.log(`[events] Favorites ${action}: track ${track_id}`); @@ -219,13 +219,13 @@ function handleFavoritesUpdated(Alpine, payload) { } if (player.currentTrack?.id === track_id) { - player.isFavorite = action === "added"; + player.isFavorite = action === 'added'; } } function handlePlaylistsUpdated(Alpine, payload) { const { action, playlist_id } = payload; - const library = Alpine.store("library"); + const library = Alpine.store('library'); console.log(`[events] Playlists ${action}: playlist ${playlist_id}`); @@ -247,23 +247,23 @@ function handleSettingsChanged(Alpine, payload) { console.log(`[events] Settings changed: ${key} =`, value); - const ui = Alpine.store("ui"); - const player = Alpine.store("player"); + const ui = Alpine.store('ui'); + const player = Alpine.store('player'); switch (key) { - case "volume": - if (player && typeof value === "number") { + case 'volume': + if (player && typeof value === 'number') { player.volume = value; } break; - case "theme": - if (ui && typeof value === "string") { + case 'theme': + if (ui && typeof value === 'string') { ui.theme = value; ui.applyTheme(); } break; - case "sidebar_width": - if (ui && typeof value === "number") { + case 'sidebar_width': + if (ui && typeof value === 'number') { ui.sidebarWidth = value; } break; @@ -279,32 +279,22 @@ function handleSettingsChanged(Alpine, payload) { * @param {object} Alpine - Alpine.js instance */ export async function initEventListeners(Alpine) { - console.log("[events] Initializing Tauri event listeners..."); + console.log('[events] Initializing Tauri event listeners...'); await subscribe(Events.LIBRARY_UPDATED, createLibraryUpdatedHandler(Alpine)); - await subscribe(Events.LIBRARY_RECONCILE, (p) => - handleLibraryReconcile(Alpine, p), - ); + await subscribe(Events.LIBRARY_RECONCILE, (p) => handleLibraryReconcile(Alpine, p)); await subscribe(Events.SCAN_PROGRESS, (p) => handleScanProgress(Alpine, p)); await subscribe(Events.SCAN_COMPLETE, (p) => handleScanComplete(Alpine, p)); await subscribe(Events.QUEUE_UPDATED, createQueueUpdatedHandler(Alpine)); - await subscribe(Events.QUEUE_STATE_CHANGED, (p) => - handleQueueStateChanged(Alpine, p), - ); - await subscribe(Events.FAVORITES_UPDATED, (p) => - handleFavoritesUpdated(Alpine, p), - ); - await subscribe(Events.PLAYLISTS_UPDATED, (p) => - handlePlaylistsUpdated(Alpine, p), - ); - await subscribe(Events.SETTINGS_CHANGED, (p) => - handleSettingsChanged(Alpine, p), - ); + await subscribe(Events.QUEUE_STATE_CHANGED, (p) => handleQueueStateChanged(Alpine, p)); + await subscribe(Events.FAVORITES_UPDATED, (p) => handleFavoritesUpdated(Alpine, p)); + await subscribe(Events.PLAYLISTS_UPDATED, (p) => handlePlaylistsUpdated(Alpine, p)); + await subscribe(Events.SETTINGS_CHANGED, (p) => handleSettingsChanged(Alpine, p)); await subscribe(Events.SETTINGS_RESET, () => { - console.log("[events] Settings reset to defaults"); + console.log('[events] Settings reset to defaults'); }); - console.log("[events] Tauri event listeners initialized"); + console.log('[events] Tauri event listeners initialized'); } /** @@ -312,7 +302,7 @@ export async function initEventListeners(Alpine) { * Call this when the app is closing */ export function cleanupEventListeners() { - console.log("[events] Cleaning up event listeners..."); + console.log('[events] Cleaning up event listeners...'); listeners.forEach((unlisten) => unlisten()); listeners.length = 0; } diff --git a/app/frontend/js/mixins/context-menu-actions.js b/app/frontend/js/mixins/context-menu-actions.js index fbd69f0a..9d95c540 100644 --- a/app/frontend/js/mixins/context-menu-actions.js +++ b/app/frontend/js/mixins/context-menu-actions.js @@ -1,5 +1,5 @@ -import { favorites } from "../api/favorites.js"; -import { playlists } from "../api/playlists.js"; +import { favorites } from '../api/favorites.js'; +import { playlists } from '../api/playlists.js'; /** * Context menu and playback actions mixin for library browser. @@ -22,22 +22,22 @@ export function contextMenuActionsMixin() { // Store track IDs globally for Tauri drop handler workaround window._mtDraggedTrackIds = trackIds; - console.log("[drag-drop]", "dragstart", { + console.log('[drag-drop]', 'dragstart', { trackCount: trackIds.length, trackIds, dataTransferData: trackIdsJson, }); - event.dataTransfer.setData("application/json", trackIdsJson); - event.dataTransfer.effectAllowed = "all"; + event.dataTransfer.setData('application/json', trackIdsJson); + event.dataTransfer.effectAllowed = 'all'; const count = trackIds.length; - const dragEl = document.createElement("div"); + const dragEl = document.createElement('div'); dragEl.className = - "fixed bg-primary text-primary-foreground px-3 py-1.5 rounded-md text-sm font-medium shadow-lg pointer-events-none"; - dragEl.textContent = count === 1 ? "1 track" : `${count} tracks`; - dragEl.style.position = "absolute"; - dragEl.style.top = "-1000px"; + 'fixed bg-primary text-primary-foreground px-3 py-1.5 rounded-md text-sm font-medium shadow-lg pointer-events-none'; + dragEl.textContent = count === 1 ? '1 track' : `${count} tracks`; + dragEl.style.position = 'absolute'; + dragEl.style.top = '-1000px'; document.body.appendChild(dragEl); event.dataTransfer.setDragImage(dragEl, 0, 0); setTimeout(() => dragEl.remove(), 0); @@ -49,10 +49,10 @@ export function contextMenuActionsMixin() { setTimeout(() => { window._mtDragJustEnded = false; window._mtDraggedTrackIds = null; - console.log("[drag-drop]", "dragJustEnded cleared"); + console.log('[drag-drop]', 'dragJustEnded cleared'); }, 1000); - console.log("[drag-drop]", "dragend", { + console.log('[drag-drop]', 'dragend', { dropEffect: event.dataTransfer?.dropEffect, }); }, @@ -74,43 +74,42 @@ export function contextMenuActionsMixin() { } const selectedCount = this.selectedTracks.size; - const trackLabel = - selectedCount === 1 ? "track" : `${selectedCount} tracks`; + const trackLabel = selectedCount === 1 ? 'track' : `${selectedCount} tracks`; const isInPlaylist = this.currentPlaylistId !== null; const menuItems = [ { - label: "Play Now", + label: 'Play Now', action: () => this.playSelected(), }, { label: `Add ${trackLabel} to Queue`, action: () => this.addSelectedToQueue(), }, - { type: "separator" }, + { type: 'separator' }, { - label: "Play Next", + label: 'Play Next', action: () => this.playSelectedNext(), }, { - label: "Add to Playlist", + label: 'Add to Playlist', hasSubmenu: true, action: () => { this.showPlaylistSubmenu = !this.showPlaylistSubmenu; }, }, { - label: "Add to Liked Songs", + label: 'Add to Liked Songs', action: () => this.toggleFavoriteFromMenu(track), }, { - label: "Go to Artist", + label: 'Go to Artist', action: () => this.goToArtist(track), disabled: selectedCount > 1 || (!track.artist && !track.album_artist), }, { - label: "Go to Album", + label: 'Go to Album', action: () => this.goToAlbum(track), disabled: selectedCount > 1 || !track.album, }, @@ -123,39 +122,38 @@ export function contextMenuActionsMixin() { if (!this.contextMenu) return; const favoriteItem = this.contextMenu.items.find( (i) => - i.label === "Add to Liked Songs" || - i.label === "Remove from Liked Songs", + i.label === 'Add to Liked Songs' || + i.label === 'Remove from Liked Songs', ); if (favoriteItem) { favoriteItem.label = result.is_favorite - ? "Remove from Liked Songs" - : "Add to Liked Songs"; + ? 'Remove from Liked Songs' + : 'Add to Liked Songs'; } }) .catch(() => {}); if (isInPlaylist) { - menuItems.push({ type: "separator" }); + menuItems.push({ type: 'separator' }); menuItems.push({ label: `Remove ${trackLabel} from Playlist`, action: () => this.removeFromPlaylist(), }); } - menuItems.push({ type: "separator" }); + menuItems.push({ type: 'separator' }); menuItems.push({ - label: "Show in Finder", + label: 'Show in Finder', action: () => this.showInFinder(track), disabled: selectedCount > 1, }); menuItems.push({ - label: - selectedCount > 1 - ? `Edit Metadata (${selectedCount} tracks)...` - : "Edit Metadata...", + label: selectedCount > 1 + ? `Edit Metadata (${selectedCount} tracks)...` + : 'Edit Metadata...', action: () => this.editMetadata(track), }); - menuItems.push({ type: "separator" }); + menuItems.push({ type: 'separator' }); menuItems.push({ label: `Remove ${trackLabel} from Library`, action: () => this.removeSelected(), @@ -182,14 +180,11 @@ export function contextMenuActionsMixin() { items: menuItems, }; this.showPlaylistSubmenu = false; - this.submenuOnLeft = - x + menuWidth + 45 + submenuWidth > window.innerWidth; + this.submenuOnLeft = x + menuWidth + 45 + submenuWidth > window.innerWidth; }, getSelectedTracks() { - return this.library.filteredTracks.filter((t) => - this.selectedTracks.has(t.id), - ); + return this.library.filteredTracks.filter((t) => this.selectedTracks.has(t.id)); }, async playSelected() { @@ -205,15 +200,15 @@ export function contextMenuActionsMixin() { async addSelectedToQueue() { const tracks = this.getSelectedTracks(); if (tracks.length > 0) { - console.log("[context-menu]", "add_to_queue", { + console.log('[context-menu]', 'add_to_queue', { trackCount: tracks.length, trackIds: tracks.map((t) => t.id), }); await this.queue.addTracks(tracks); this.$store.ui.toast( - `Added ${tracks.length} track${tracks.length > 1 ? "s" : ""} to queue`, - "success", + `Added ${tracks.length} track${tracks.length > 1 ? 's' : ''} to queue`, + 'success', ); } this.contextMenu = null; @@ -222,15 +217,15 @@ export function contextMenuActionsMixin() { async playSelectedNext() { const tracks = this.getSelectedTracks(); if (tracks.length > 0) { - console.log("[context-menu]", "play_next", { + console.log('[context-menu]', 'play_next', { trackCount: tracks.length, trackIds: tracks.map((t) => t.id), }); await this.queue.playNextTracks(tracks); this.$store.ui.toast( - `Playing ${tracks.length} track${tracks.length > 1 ? "s" : ""} next`, - "success", + `Playing ${tracks.length} track${tracks.length > 1 ? 's' : ''} next`, + 'success', ); } this.contextMenu = null; @@ -240,7 +235,7 @@ export function contextMenuActionsMixin() { const tracks = this.getSelectedTracks(); if (tracks.length === 0) return; - console.log("[context-menu]", "add_to_playlist", { + console.log('[context-menu]', 'add_to_playlist', { playlistId, trackCount: tracks.length, trackIds: tracks.map((t) => t.id), @@ -250,27 +245,27 @@ export function contextMenuActionsMixin() { const trackIds = tracks.map((t) => t.id); const result = await playlists.addTracks(playlistId, trackIds); const playlist = this.playlists.find((p) => p.id === playlistId); - const playlistName = playlist?.name || "playlist"; + const playlistName = playlist?.name || 'playlist'; if (result.added > 0) { this.$store.ui.toast( - `Added ${result.added} track${result.added > 1 ? "s" : ""} to "${playlistName}"`, - "success", + `Added ${result.added} track${result.added > 1 ? 's' : ''} to "${playlistName}"`, + 'success', ); } else { this.$store.ui.toast( - `Track${tracks.length > 1 ? "s" : ""} already in "${playlistName}"`, - "info", + `Track${tracks.length > 1 ? 's' : ''} already in "${playlistName}"`, + 'info', ); } - window.dispatchEvent(new CustomEvent("mt:playlists-updated")); + window.dispatchEvent(new CustomEvent('mt:playlists-updated')); } catch (error) { - console.error("[context-menu]", "add_to_playlist_error", { + console.error('[context-menu]', 'add_to_playlist_error', { playlistId, error: error.message, }); - this.$store.ui.toast("Failed to add to playlist", "error"); + this.$store.ui.toast('Failed to add to playlist', 'error'); } this.contextMenu = null; @@ -292,11 +287,11 @@ export function contextMenuActionsMixin() { } this.library.refreshIfLikedSongs(); } catch (error) { - console.error("[context-menu]", "toggle_favorite_error", { + console.error('[context-menu]', 'toggle_favorite_error', { trackId: track.id, error: error.message, }); - this.$store.ui.toast("Failed to update liked songs", "error"); + this.$store.ui.toast('Failed to update liked songs', 'error'); } }, @@ -306,7 +301,7 @@ export function contextMenuActionsMixin() { // Dispatch event - albums-browser handles view switch and album opening window.dispatchEvent( - new CustomEvent("mt:navigate-to-album", { + new CustomEvent('mt:navigate-to-album', { detail: { album: track.album, albumArtist: track.album_artist || track.artist, @@ -322,7 +317,7 @@ export function contextMenuActionsMixin() { // Dispatch event - artists-browser handles view switch and artist selection window.dispatchEvent( - new CustomEvent("mt:navigate-to-artist", { + new CustomEvent('mt:navigate-to-artist', { detail: { artist }, }), ); @@ -336,7 +331,7 @@ export function contextMenuActionsMixin() { const trackIds = tracks.map((t) => t.id); window.dispatchEvent( - new CustomEvent("mt:create-playlist-with-tracks", { + new CustomEvent('mt:create-playlist-with-tracks', { detail: { trackIds }, }), ); @@ -364,8 +359,8 @@ export function contextMenuActionsMixin() { } this.$store.ui.toast( - `Removed ${tracks.length} track${tracks.length > 1 ? "s" : ""} from playlist`, - "success", + `Removed ${tracks.length} track${tracks.length > 1 ? 's' : ''} from playlist`, + 'success', ); const playlist = await playlists.get(this.currentPlaylistId); @@ -379,10 +374,10 @@ export function contextMenuActionsMixin() { this.library.applyFilters(); this.clearSelection(); - window.dispatchEvent(new CustomEvent("mt:playlists-updated")); + window.dispatchEvent(new CustomEvent('mt:playlists-updated')); } catch (error) { - console.error("Failed to remove from playlist:", error); - this.$store.ui.toast("Failed to remove from playlist", "error"); + console.error('Failed to remove from playlist:', error); + this.$store.ui.toast('Failed to remove from playlist', 'error'); } this.contextMenu = null; @@ -392,15 +387,15 @@ export function contextMenuActionsMixin() { const trackPath = track?.filepath || track?.path; if (!trackPath) { console.error( - "Cannot show in folder: track has no filepath/path", + 'Cannot show in folder: track has no filepath/path', track, ); - this.$store.ui.toast("Cannot locate file", "error"); + this.$store.ui.toast('Cannot locate file', 'error'); this.contextMenu = null; return; } - console.log("[context-menu]", "show_in_finder", { + console.log('[context-menu]', 'show_in_finder', { trackId: track.id, trackTitle: track.title, trackPath, @@ -408,17 +403,17 @@ export function contextMenuActionsMixin() { try { if (window.__TAURI__) { - const { revealItemInDir } = await import("@tauri-apps/plugin-opener"); + const { revealItemInDir } = await import('@tauri-apps/plugin-opener'); await revealItemInDir(trackPath); } else { - console.log("Show in folder (browser mode):", trackPath); + console.log('Show in folder (browser mode):', trackPath); } } catch (error) { - console.error("[context-menu]", "show_in_finder_error", { + console.error('[context-menu]', 'show_in_finder_error', { trackId: track.id, error: error.message, }); - this.$store.ui.toast("Failed to open folder", "error"); + this.$store.ui.toast('Failed to open folder', 'error'); } this.contextMenu = null; }, @@ -429,14 +424,14 @@ export function contextMenuActionsMixin() { tracks.push(track); } - console.log("[context-menu]", "edit_metadata", { + console.log('[context-menu]', 'edit_metadata', { trackCount: tracks.length, trackIds: tracks.map((t) => t.id), anchorTrackId: track.id, }); this.contextMenu = null; - this.$store.ui.openModal("editMetadata", { + this.$store.ui.openModal('editMetadata', { tracks, library: this.library, anchorTrackId: track.id, @@ -447,23 +442,21 @@ export function contextMenuActionsMixin() { const tracks = this.getSelectedTracks(); if (tracks.length === 0) return; - console.log("[context-menu]", "remove_from_library", { + console.log('[context-menu]', 'remove_from_library', { trackCount: tracks.length, trackIds: tracks.map((t) => t.id), }); - const confirmMsg = - tracks.length === 1 - ? `Remove "${tracks[0].title}" from library?` - : `Remove ${tracks.length} tracks from library?`; + const confirmMsg = tracks.length === 1 + ? `Remove "${tracks[0].title}" from library?` + : `Remove ${tracks.length} tracks from library?`; this.contextMenu = null; - const confirmed = - (await window.__TAURI__?.dialog?.confirm(confirmMsg, { - title: "Remove from Library", - kind: "warning", - })) ?? window.confirm(confirmMsg); + const confirmed = (await window.__TAURI__?.dialog?.confirm(confirmMsg, { + title: 'Remove from Library', + kind: 'warning', + })) ?? window.confirm(confirmMsg); if (confirmed) { const trackIds = tracks.map((t) => t.id); @@ -475,8 +468,8 @@ export function contextMenuActionsMixin() { this.library._removeFromQueue(idSet); this.selectedTracks.clear(); this.$store.ui.toast( - `Removed ${tracks.length} track${tracks.length > 1 ? "s" : ""}`, - "success", + `Removed ${tracks.length} track${tracks.length > 1 ? 's' : ''}`, + 'success', ); const { invoke } = window.__TAURI__.core; @@ -484,28 +477,26 @@ export function contextMenuActionsMixin() { try { if (isDeletingAll) { // Remove watched folders first so watcher can't re-add tracks - const folders = await invoke("watched_folders_list"); + const folders = await invoke('watched_folders_list'); await Promise.allSettled( - (folders || []).map((f) => - invoke("watched_folders_remove", { id: f.id }), - ), + (folders || []).map((f) => invoke('watched_folders_remove', { id: f.id })), ); // Single SQL wipe of library, favorites, playlist_items - await invoke("library_delete_all"); + await invoke('library_delete_all'); console.log( - "[library-browser] Deleted all tracks and removed watched folders", + '[library-browser] Deleted all tracks and removed watched folders', ); } else { // Batch delete by IDs in a single IPC call - await invoke("library_delete_tracks", { trackIds }); + await invoke('library_delete_tracks', { trackIds }); console.log( - "[library-browser] Batch deleted", + '[library-browser] Batch deleted', trackIds.length, - "tracks", + 'tracks', ); } } catch (err) { - console.error("[library-browser] Delete failed:", err); + console.error('[library-browser] Delete failed:', err); this.library.fetchTracks(); } } diff --git a/app/frontend/js/stores/library.js b/app/frontend/js/stores/library.js index 1c1d7038..eaa15fe8 100644 --- a/app/frontend/js/stores/library.js +++ b/app/frontend/js/stores/library.js @@ -9,12 +9,12 @@ * recent, playlists) load all tracks in a single fetch. */ -import { library as libraryApi } from "../api/library.js"; +import { library as libraryApi } from '../api/library.js'; import { buildCacheEntry, createCacheSaver, loadCacheFromSettings, -} from "../utils/library-cache.js"; +} from '../utils/library-cache.js'; import { applySectionData, backgroundRefreshLibrary, @@ -26,7 +26,7 @@ import { removeFromQueue, removeTracksLocallyOp, scanPaths, -} from "../utils/library-operations.js"; +} from '../utils/library-operations.js'; const { listen } = window.__TAURI__?.event ?? { listen: () => Promise.resolve(() => {}), @@ -36,11 +36,11 @@ const { listen } = window.__TAURI__?.event ?? { export { applySectionData }; export function createLibraryStore(Alpine) { - Alpine.store("library", { + Alpine.store('library', { // Search and filter state - searchQuery: "", - sortBy: "default", - sortOrder: "asc", + searchQuery: '', + sortBy: 'default', + sortOrder: 'asc', currentSection: getInitialSection(), // Loading state @@ -88,7 +88,7 @@ export function createLibraryStore(Alpine) { this.totalTracks = cached.totalTracks; this.totalDuration = cached.totalDuration; this._lastLoadedSection = this.currentSection; - console.log("[library] showing cached summary on init:", { + console.log('[library] showing cached summary on init:', { section: this.currentSection, totalTracks: cached.totalTracks, }); @@ -101,10 +101,10 @@ export function createLibraryStore(Alpine) { async _setupWatchedFolderListener() { this._watchedFolderListener = await listen( - "watched-folder:results", + 'watched-folder:results', (event) => { const { added, updated, deleted } = event.payload || {}; - console.log("[library] watched-folder:results", { + console.log('[library] watched-folder:results', { added, updated, deleted, @@ -112,7 +112,7 @@ export function createLibraryStore(Alpine) { if (added > 0 || updated > 0 || deleted > 0) { console.log( - "[library] Reloading library after watched folder scan", + '[library] Reloading library after watched folder scan', ); this._clearCache(); this.load({ forceReload: true }); @@ -150,10 +150,10 @@ export function createLibraryStore(Alpine) { _clearCache(section = null) { if (section) { delete this._sectionCache[section]; - console.log("[library] cache cleared for section:", section); + console.log('[library] cache cleared for section:', section); } else { this._sectionCache = {}; - console.log("[library] cache cleared (all sections)"); + console.log('[library] cache cleared (all sections)'); } this._persistCache(); }, @@ -188,21 +188,19 @@ export function createLibraryStore(Alpine) { try { const sortKeyMap = { - default: "artist", - index: "track_number", - dateAdded: "added_date", - lastPlayed: "last_played", - playCount: "play_count", - year: "date", - genre: "genre", - trackTotal: "track_total", - discNumber: "disc_number", + default: 'artist', + index: 'track_number', + dateAdded: 'added_date', + lastPlayed: 'last_played', + playCount: 'play_count', + year: 'date', + genre: 'genre', + trackTotal: 'track_total', + discNumber: 'disc_number', }; - const uiStore = Alpine.store("ui"); - const ignoreWords = uiStore.sortIgnoreWords - ? uiStore.sortIgnoreWordsList - : null; + const uiStore = Alpine.store('ui'); + const ignoreWords = uiStore.sortIgnoreWords ? uiStore.sortIgnoreWordsList : null; const data = await libraryApi.getTracks({ search: this.searchQuery.trim() || null, @@ -226,13 +224,13 @@ export function createLibraryStore(Alpine) { // Trigger Alpine reactivity by incrementing version this._dataVersion++; - console.log("[library] page loaded:", { + console.log('[library] page loaded:', { pageIndex, trackCount: tracks.length, totalPages: Math.ceil(this.totalTracks / this._pageSize), }); } catch (error) { - console.error("[library] page fetch failed:", { + console.error('[library] page fetch failed:', { pageIndex, error: error.message, }); @@ -326,24 +324,22 @@ export function createLibraryStore(Alpine) { _getSortParams() { const sortKeyMap = { - default: "artist", - index: "track_number", - dateAdded: "added_date", - lastPlayed: "last_played", - playCount: "play_count", - year: "date", - genre: "genre", - trackTotal: "track_total", - discNumber: "disc_number", + default: 'artist', + index: 'track_number', + dateAdded: 'added_date', + lastPlayed: 'last_played', + playCount: 'play_count', + year: 'date', + genre: 'genre', + trackTotal: 'track_total', + discNumber: 'disc_number', }; - const uiStore = Alpine.store("ui"); + const uiStore = Alpine.store('ui'); return { search: this.searchQuery.trim() || null, sort: sortKeyMap[this.sortBy] || this.sortBy, order: this.sortOrder, - ignoreWords: uiStore.sortIgnoreWords - ? uiStore.sortIgnoreWordsList - : null, + ignoreWords: uiStore.sortIgnoreWords ? uiStore.sortIgnoreWordsList : null, }; }, @@ -383,41 +379,40 @@ export function createLibraryStore(Alpine) { }, loadFavorites() { - return this._loadSection("liked", null); + return this._loadSection('liked', null); }, _backgroundRefreshFavorites() { // Preserve original matching: currentSection === 'liked' OR _lastLoadedSection === 'liked' - const section = - this.currentSection === "liked" || this._lastLoadedSection === "liked" - ? "liked" - : null; + const section = this.currentSection === 'liked' || this._lastLoadedSection === 'liked' + ? 'liked' + : null; if (!section) return; - return this._backgroundRefreshSection("liked", null); + return this._backgroundRefreshSection('liked', null); }, loadRecentlyPlayed(days = 14) { - return this._loadSection("recent", null, { days }); + return this._loadSection('recent', null, { days }); }, _backgroundRefreshRecentlyPlayed(days = 14) { - return this._backgroundRefreshSection("recent", null, { days }); + return this._backgroundRefreshSection('recent', null, { days }); }, loadRecentlyAdded(days = 14) { - return this._loadSection("added", null, { days }); + return this._loadSection('added', null, { days }); }, _backgroundRefreshRecentlyAdded(days = 14) { - return this._backgroundRefreshSection("added", null, { days }); + return this._backgroundRefreshSection('added', null, { days }); }, loadTop25() { - return this._loadSection("top25", null); + return this._loadSection('top25', null); }, _backgroundRefreshTop25() { - return this._backgroundRefreshSection("top25", null); + return this._backgroundRefreshSection('top25', null); }, loadPlaylist(playlistId) { @@ -432,19 +427,19 @@ export function createLibraryStore(Alpine) { }; this._persistCache(); - console.log("[navigation]", "load_playlist_complete", { + console.log('[navigation]', 'load_playlist_complete', { playlistId, trackCount: this.filteredTracks.length, }); return data; }; - console.log("[navigation]", "load_playlist", { playlistId }); + console.log('[navigation]', 'load_playlist', { playlistId }); // The unified endpoint returns flat Track objects for playlists return this._loadSection(section, null, { onSuccess: cachePlaylist, - logTag: "navigation", + logTag: 'navigation', }); }, @@ -469,19 +464,19 @@ export function createLibraryStore(Alpine) { // ----------------------------------------------------------------------- setSection(section) { - console.log("[navigation]", "switch_section", { + console.log('[navigation]', 'switch_section', { previousSection: this.currentSection, newSection: section, }); this.currentSection = section; window.dispatchEvent( - new CustomEvent("mt:section-change", { detail: { section } }), + new CustomEvent('mt:section-change', { detail: { section } }), ); }, refreshIfLikedSongs() { - if (this.currentSection === "liked") { + if (this.currentSection === 'liked') { this.loadFavorites(); } }, @@ -505,13 +500,13 @@ export function createLibraryStore(Alpine) { }, setSortBy(field) { - console.log("[library]", "setSortBy", { field }); + console.log('[library]', 'setSortBy', { field }); if (this.sortBy === field) { - this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc"; + this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; } else { this.sortBy = field; - this.sortOrder = "asc"; + this.sortOrder = 'asc'; } this.load({ forceReload: true }); @@ -566,7 +561,7 @@ export function createLibraryStore(Alpine) { this._removeFromQueue(new Set([trackId])); await libraryApi.deleteTrack(trackId); } catch (error) { - console.error("Failed to remove track:", error); + console.error('Failed to remove track:', error); // Rollback: re-fetch from backend this.fetchTracks(); throw error; @@ -591,7 +586,7 @@ export function createLibraryStore(Alpine) { try { return await libraryApi.getTrack(trackId); } catch (error) { - console.error("[library] getTrackAsync failed:", error); + console.error('[library] getTrackAsync failed:', error); return null; } }, @@ -615,7 +610,7 @@ export function createLibraryStore(Alpine) { return offset; } catch (error) { - console.error("[library] _jumpToPrefix failed:", error); + console.error('[library] _jumpToPrefix failed:', error); return null; } }, @@ -625,7 +620,7 @@ export function createLibraryStore(Alpine) { // ----------------------------------------------------------------------- async addToQueue(track, playNow = false) { - await Alpine.store("queue").add(track, playNow); + await Alpine.store('queue').add(track, playNow); }, async addAllToQueue(playNow = false) { @@ -633,11 +628,11 @@ export function createLibraryStore(Alpine) { if (this._isPaginated() && !this._allPagesLoaded) { await this._loadAllPages(); } - await Alpine.store("queue").add(this.filteredTracks, playNow); + await Alpine.store('queue').add(this.filteredTracks, playNow); }, async playNow(track) { - const queue = Alpine.store("queue"); + const queue = Alpine.store('queue'); await queue.clear(); await queue.add(track, true); }, @@ -697,7 +692,7 @@ export function createLibraryStore(Alpine) { } } } catch (error) { - console.error("[library] Failed to rescan track:", error); + console.error('[library] Failed to rescan track:', error); } }, @@ -712,7 +707,7 @@ export function createLibraryStore(Alpine) { this.scanProgress = Math.min(99, scanned); } - console.log("[library] scan progress:", { + console.log('[library] scan progress:', { jobId, status, scanned, diff --git a/app/frontend/js/stores/queue.js b/app/frontend/js/stores/queue.js index e5b908cd..8c9a356e 100644 --- a/app/frontend/js/stores/queue.js +++ b/app/frontend/js/stores/queue.js @@ -4,6 +4,12 @@ * The queue maintains tracks in PLAY ORDER - the order shown in the Now Playing * view is always the order tracks will be played. When shuffle is enabled, * the items array is physically reordered. + * + * State machine logic (shuffle, navigation, play-next, history, integrity) + * lives in the Rust backend. This store is a thin reactive layer that: + * 1. Calls backend commands for state transitions + * 2. Applies returned state snapshots + * 3. Exposes computed UI properties */ import { queue as queueApi } from '../api/queue.js'; @@ -19,28 +25,15 @@ export function createQueueStore(Alpine) { loop: 'none', // 'none', 'all', 'one' stopAfterCurrent: false, // Stop playback when current track ends - // Repeat-one "play once more" state - _repeatOnePending: false, - // Loading state loading: false, - // Original order preserved for unshuffle - _originalOrder: [], - - // Play history for prev button navigation - _playHistory: [], - _maxHistorySize: 100, - // Flag to prevent event listener from overriding during initialization _initializing: false, // Flag to prevent event listener from overriding during queue operations _updating: false, - // Track IDs added via "Play Next" - these are pinned after the current track during shuffle - _playNextTrackIds: new Set(), - /** * Initialize queue from backend */ @@ -66,10 +59,6 @@ export function createQueueStore(Alpine) { this.currentIndex = -1; this.shuffle = false; this.loop = 'none'; - this._originalOrder = [...this.items]; - this._repeatOnePending = false; - this._playHistory = []; - this._playNextTrackIds = new Set(); // Persist the reset state to backend try { @@ -88,9 +77,6 @@ export function createQueueStore(Alpine) { const rawItems = data.items || []; this.items = rawItems.map((item) => item.track || item); this.currentIndex = data.currentIndex ?? -1; - if (!this.shuffle) { - this._originalOrder = [...this.items]; - } } catch (error) { console.error('Failed to load queue:', error); } finally { @@ -132,9 +118,6 @@ export function createQueueStore(Alpine) { const data = await queueApi.get(); const rawItems = data.items || []; this.items = rawItems.map((item) => item.track || item); - if (!this.shuffle) { - this._originalOrder = [...this.items]; - } // Restore currentIndex by finding the currently playing track if (currentTrackId !== null) { @@ -156,35 +139,32 @@ export function createQueueStore(Alpine) { }, /** - * Save queue state to backend + * Apply a state snapshot from the backend + * @param {Object} snapshot - QueueStateSnapshot from backend */ - async save() { - try { - await queueApi.save({ - items: this.items, - currentIndex: this.currentIndex, - shuffle: this.shuffle, - loop: this.loop, - }); - } catch (error) { - console.error('Failed to save queue:', error); - } + _applySnapshot(snapshot) { + if (!snapshot) return; + this.items = (snapshot.items || []).map((item) => item.track || item); + this.currentIndex = snapshot.current_index ?? this.currentIndex; + this.shuffle = snapshot.shuffle_enabled ?? this.shuffle; + this.loop = snapshot.loop_mode ?? this.loop; }, /** - * Sync full queue state to backend (clear and rebuild) - * Used when queue order changes in ways that can't be expressed as incremental operations + * Apply a navigation result from the backend + * @param {Object} result - QueueNavigationResult from backend + * @returns {string} The action taken: 'play', 'stop', or 'seek_zero' */ - async _syncQueueToBackend() { - try { - await queueApi.clear(); - if (this.items.length > 0) { - const trackIds = this.items.map((t) => t.id); - await queueApi.add(trackIds); - } - } catch (error) { - console.error('[queue] Failed to sync to backend:', error); + async _applyNavigationResult(result) { + if (!result) return 'stop'; + + this._applySnapshot(result.snapshot); + + if (result.action === 'play' && result.track) { + Alpine.store('player').updateTrackState(result.track, result.duration_ms); } + + return result.action; }, /** @@ -205,7 +185,6 @@ export function createQueueStore(Alpine) { // Update local state this.items.push(...tracksArray); - this._originalOrder.push(...tracksArray); // Persist to backend try { @@ -272,54 +251,31 @@ export function createQueueStore(Alpine) { }, /** - * Insert tracks to play next (after currently playing track) + * Insert tracks to play next (after currently playing track). + * Backend handles move semantics, offset tracking, and play-next ID management. * @param {Array|Object} tracks - Track(s) to insert */ async playNextTracks(tracks) { const tracksArray = Array.isArray(tracks) ? tracks : [tracks]; if (tracksArray.length === 0) return; - // Move semantics: remove existing copies from queue before re-inserting at play-next position. - // This handles the case where the full library is in the queue and the user - // wants to move an existing track to play next. - // Skip tracks that are currently playing (can't move the current track). - const currentTrackId = this.currentIndex >= 0 ? this.items[this.currentIndex]?.id : null; - const tracksToInsert = []; - for (const t of tracksArray) { - if (t.id === currentTrackId) continue; // skip currently playing track - const existingIdx = this.items.findIndex((item) => item.id === t.id); - if (existingIdx >= 0) { - this.items.splice(existingIdx, 1); - const origIdx = this._originalOrder.findIndex((item) => item.id === t.id); - if (origIdx >= 0) this._originalOrder.splice(origIdx, 1); - if (existingIdx < this.currentIndex) { - this.currentIndex--; - } - } - tracksToInsert.push(t); - } - if (tracksToInsert.length === 0) return; - - // Append after any previously queued-next tracks (not before them) - if (!this._playNextOffset) this._playNextOffset = 0; - const insertIndex = (this.currentIndex >= 0 ? this.currentIndex + 1 : 0) + - this._playNextOffset; - console.log('[queue]', 'play_next_tracks', { - count: tracksToInsert.length, - trackIds: tracksToInsert.map((t) => t.id), - insertIndex, - playNextOffset: this._playNextOffset, + count: tracksArray.length, + trackIds: tracksArray.map((t) => t.id), }); - this._playNextOffset += tracksToInsert.length; - - // Track these as pinned play-next tracks (preserved during shuffle) - for (const t of tracksToInsert) { - this._playNextTrackIds.add(t.id); + this._updating = true; + try { + const trackIds = tracksArray.map((t) => t.id); + const snapshot = await queueApi.addPlayNext(trackIds); + this._applySnapshot(snapshot); + } catch (error) { + console.error('[queue] Failed to add play-next:', error); + } finally { + setTimeout(() => { + this._updating = false; + }, 50); } - - await this.insert(insertIndex, tracksToInsert); }, /** @@ -345,15 +301,6 @@ export function createQueueStore(Alpine) { // Update local state this.items.splice(index, 1); - // Also remove from _originalOrder so unshuffle stays consistent - if (removedTrack) { - const origIdx = this._originalOrder.findIndex((t) => t.id === removedTrack.id); - if (origIdx >= 0) { - this._originalOrder.splice(origIdx, 1); - } - this._playNextTrackIds.delete(removedTrack.id); - } - // Adjust current index if (index < this.currentIndex) { this.currentIndex--; @@ -390,9 +337,6 @@ export function createQueueStore(Alpine) { // Update local state this.items = []; this.currentIndex = -1; - this._originalOrder = []; - this._playNextTrackIds = new Set(); - // _playHistory intentionally preserved - persists across queue rebuilds Alpine.store('player')?.stop(); @@ -447,75 +391,42 @@ export function createQueueStore(Alpine) { /** * Play track at specific index * @param {number} index - Index to play - * @param {boolean} fromNavigation - If true, this is from playNext/playPrevious and history shouldn't be cleared + * @param {boolean} fromNavigation - If true, this is from backend navigation (history already handled) */ async playIndex(index, fromNavigation = false) { if (index < 0 || index >= this.items.length) return; - // Push current track to history on manual jumps (not from prev/next navigation) - if (!fromNavigation && this.currentIndex >= 0 && this.currentIndex !== index) { - this._pushToHistory(this.currentIndex); - } - - // Reset play-next insertion offset when track changes - this._playNextOffset = 0; - this.currentIndex = index; const track = this.items[index]; - // If this was a play-next track, it's been consumed - this._playNextTrackIds.delete(track.id); - await Alpine.store('player').playTrack(track); await queueApi.setCurrentIndex(this.currentIndex); }, + /** + * Play next track. Backend handles repeat-one two-phase, loop modes, + * history push, reshuffle on loop restart, and audio playback. + */ async playNext() { if (this.items.length === 0) return; - // Stop after current track if flag is set + // Stop after current track if flag is set (frontend-only concern) if (this.stopAfterCurrent) { this.stopAfterCurrent = false; Alpine.store('player').isPlaying = false; return; } - // Prevent QUEUE_STATE_CHANGED event from overwriting state during playNext this._updating = true; try { - if (this._repeatOnePending) { - // Second call after repeat-one replay — clear flag and advance normally - this._repeatOnePending = false; - } else if (this.loop === 'one') { - // First call with loop-one — replay track and untoggle icon immediately - this._repeatOnePending = true; - this.loop = 'none'; - await queueApi.setLoop(this.loop); - await this.playIndex(this.currentIndex, true); - return; - } + const result = await queueApi.playNextTrack(); + const action = await this._applyNavigationResult(result); - // Push current track to history before advancing - if (this.currentIndex >= 0) { - this._pushToHistory(this.currentIndex); + if (action === 'stop') { + Alpine.store('player').isPlaying = false; } - - let nextIndex = this.currentIndex + 1; - - if (nextIndex >= this.items.length) { - if (this.loop === 'all') { - if (this.shuffle) { - // Use special reshuffle that puts just-played track at END (task-222) - this._reshuffleForLoopRestart(); - } - nextIndex = 0; - } else { - Alpine.store('player').isPlaying = false; - return; - } - } - - await this.playIndex(nextIndex, true); + } catch (error) { + console.error('[queue] playNext failed:', error); } finally { setTimeout(() => { this._updating = false; @@ -523,54 +434,47 @@ export function createQueueStore(Alpine) { } }, + /** + * Play previous track. Backend handles >3sec restart, history pop, + * fallback decrement, and loop wraparound. + */ async playPrevious() { if (this.items.length === 0) return; - const player = Alpine.store('player'); - - // If > 3 seconds into track, restart current track instead - if (player.currentTime > 3000) { - await player.seek(0); - return; - } - - // Try to use play history first (tracks matched by ID, survives queue rebuilds) - if (this._playHistory.length > 0) { - const historyIndex = this._popFromHistory(); - if (historyIndex >= 0) { - await this.playIndex(historyIndex, true); - return; - } - } - - // Fallback: navigate backward in queue array - let prevIndex = this.currentIndex - 1; + this._updating = true; + try { + const player = Alpine.store('player'); + const currentTimeMs = player.currentTime || 0; + const result = await queueApi.playPreviousTrack(currentTimeMs); + const action = await this._applyNavigationResult(result); - if (prevIndex < 0) { - if (this.loop === 'all') { - prevIndex = this.items.length - 1; - } else { - prevIndex = 0; + if (action === 'seek_zero') { + await player.seek(0); } + } catch (error) { + console.error('[queue] playPrevious failed:', error); + } finally { + setTimeout(() => { + this._updating = false; + }, 50); } - - await this.playIndex(prevIndex, true); }, /** - * Manual skip to next track (user-initiated) - * If in repeat-one mode, reverts to 'all' and skips + * Manual skip to next track (user-initiated). + * Backend overrides repeat-one mode before advancing. */ async skipNext() { - if (this.loop === 'one') { - this.loop = 'all'; - this._repeatOnePending = false; - // Loop state is session-only, no persistence needed - } - // Prevent QUEUE_STATE_CHANGED event from overwriting state during skip this._updating = true; try { - await this._doSkipNext(); + const result = await queueApi.skipNext(); + const action = await this._applyNavigationResult(result); + + if (action === 'stop') { + Alpine.store('player').isPlaying = false; + } + } catch (error) { + console.error('[queue] skipNext failed:', error); } finally { setTimeout(() => { this._updating = false; @@ -579,19 +483,22 @@ export function createQueueStore(Alpine) { }, /** - * Manual skip to previous track (user-initiated) - * If in repeat-one mode, reverts to 'all' and skips + * Manual skip to previous track (user-initiated). + * Backend overrides repeat-one mode before going back. */ async skipPrevious() { - if (this.loop === 'one') { - this.loop = 'all'; - this._repeatOnePending = false; - // Loop state is session-only, no persistence needed - } - // Prevent QUEUE_STATE_CHANGED event from overwriting state during skip this._updating = true; try { - await this.playPrevious(); + const player = Alpine.store('player'); + const currentTimeMs = player.currentTime || 0; + const result = await queueApi.skipPrevious(currentTimeMs); + const action = await this._applyNavigationResult(result); + + if (action === 'seek_zero') { + await player.seek(0); + } + } catch (error) { + console.error('[queue] skipPrevious failed:', error); } finally { setTimeout(() => { this._updating = false; @@ -599,52 +506,17 @@ export function createQueueStore(Alpine) { } }, - async _doSkipNext() { - if (this.items.length === 0) return; - - // Push current track to history before advancing (preserves prev button navigation) - if (this.currentIndex >= 0) { - this._pushToHistory(this.currentIndex); - } - - let nextIndex = this.currentIndex + 1; - if (nextIndex >= this.items.length) { - nextIndex = 0; - } - - await this.playIndex(nextIndex, true); // fromNavigation=true preserves history - }, - + /** + * Toggle shuffle on/off. Backend handles Fisher-Yates, original order + * save/restore, play-next pinning, and current-track-at-index-0. + */ async toggleShuffle() { - // CRITICAL: Prevent QUEUE_STATE_CHANGED event from overwriting state during operation this._updating = true; - try { - this.shuffle = !this.shuffle; - - if (this.shuffle) { - // Save original order before shuffling - this._originalOrder = [...this.items]; - this._shuffleItems(); - // Don't clear history - user can still go back to previously played tracks - // Don't call setCurrentIndex - same track is playing, just at index 0 now - } else { - // Restore original order - const currentTrack = this.items[this.currentIndex]; - this.items = [...this._originalOrder]; - this.currentIndex = this.items.findIndex((t) => t.id === currentTrack?.id); - if (this.currentIndex < 0) { - this.currentIndex = this.items.length > 0 ? 0 : -1; - } - // History preserved - track objects are matched by ID, survives reorder - } - - // Persist shuffle state to backend - await queueApi.setShuffle(this.shuffle); - - // Sync queue order to backend, then set currentIndex AFTER so clear() doesn't leave stale state - await this._syncQueueToBackend(); - await queueApi.setCurrentIndex(this.currentIndex); + const snapshot = await queueApi.setShuffle(!this.shuffle); + this._applySnapshot(snapshot); + } catch (error) { + console.error('[queue] toggleShuffle failed:', error); } finally { setTimeout(() => { this._updating = false; @@ -652,110 +524,6 @@ export function createQueueStore(Alpine) { } }, - _shuffleItems() { - if (this.items.length < 2) return; - - const currentTrack = this.currentIndex >= 0 ? this.items[this.currentIndex] : null; - - // Separate play-next tracks (pinned) from regular tracks - const pinnedTracks = []; - const regularTracks = []; - - for (let i = 0; i < this.items.length; i++) { - if (i === this.currentIndex) continue; - if (this._playNextTrackIds.has(this.items[i].id)) { - pinnedTracks.push(this.items[i]); - } else { - regularTracks.push(this.items[i]); - } - } - - // Fisher-Yates shuffle only the regular tracks - for (let i = regularTracks.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [regularTracks[i], regularTracks[j]] = [regularTracks[j], regularTracks[i]]; - } - - if (currentTrack) { - // Current track at 0, pinned play-next tracks next, then shuffled rest (task-213) - this.items = [currentTrack, ...pinnedTracks, ...regularTracks]; - this.currentIndex = 0; - } else { - this.items = [...pinnedTracks, ...regularTracks]; - } - - this._validateQueueIntegrity(); - }, - - /** - * Reshuffle for loop restart - ensures just-played track is NOT at index 0 (task-222) - * Called when loop=all and reaching end of queue with shuffle enabled. - */ - _reshuffleForLoopRestart() { - if (this.items.length < 2) return; - - const justPlayedTrack = this.items[this.currentIndex]; - const otherTracks = this.items.filter((_, i) => i !== this.currentIndex); - - // Fisher-Yates shuffle of other tracks - for (let i = otherTracks.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [otherTracks[i], otherTracks[j]] = [otherTracks[j], otherTracks[i]]; - } - - // Put just-played track at END (plays last in next cycle) - this.items = [...otherTracks, justPlayedTrack]; - this._originalOrder = [...this.items]; - - this._validateQueueIntegrity(); - }, - - /** - * Validate queue integrity - check for duplicate track IDs - */ - _validateQueueIntegrity() { - const ids = this.items.map((t) => t.id); - const uniqueIds = new Set(ids); - if (uniqueIds.size !== ids.length) { - console.error('[queue] Duplicate tracks detected!', { - total: ids.length, - unique: uniqueIds.size, - }); - return false; - } - return true; - }, - - /** - * Push track at index to play history (stores track object, not index) - * @param {number} index - Index of track to push to history - */ - _pushToHistory(index) { - const track = this.items[index]; - if (!track) return; - this._playHistory.push(track); - - // Limit history size to prevent memory issues - if (this._playHistory.length > this._maxHistorySize) { - this._playHistory.shift(); - } - }, - - /** - * Pop track from history and find its current index in the queue. - * Tracks are matched by ID so history survives queue rebuilds. - * @returns {number} Current index of the historical track, or -1 if not found - */ - _popFromHistory() { - while (this._playHistory.length > 0) { - const track = this._playHistory.pop(); - const idx = this.items.findIndex((t) => t.id === track.id); - if (idx >= 0) return idx; - // Track no longer in queue - keep draining until we find one that is - } - return -1; - }, - async shuffleQueue() { if (this.items.length < 2) return; @@ -764,12 +532,12 @@ export function createQueueStore(Alpine) { currentIndex: this.currentIndex, }); - // Update local state - this._shuffleItems(); - this._originalOrder = [...this.items]; - - // Sync shuffled order to backend - await this._syncQueueToBackend(); + try { + await queueApi.shuffle(true); + await this.load(); + } catch (error) { + console.error('[queue] shuffleQueue failed:', error); + } }, async cycleLoop() { @@ -783,7 +551,6 @@ export function createQueueStore(Alpine) { }); this.loop = newMode; - this._repeatOnePending = false; await queueApi.setLoop(this.loop); }, @@ -799,11 +566,27 @@ export function createQueueStore(Alpine) { }); this.loop = mode; - this._repeatOnePending = false; await queueApi.setLoop(this.loop); } }, + /** + * Run integrity check via backend + */ + async checkIntegrity() { + try { + const report = await queueApi.checkIntegrity(); + if (report.repaired) { + console.log('[queue] Integrity check repaired issues:', report); + await this.load(); + } + return report; + } catch (error) { + console.error('[queue] Integrity check failed:', error); + return null; + } + }, + /** * Get tracks (alias for items, used by UI templates) */ diff --git a/app/frontend/js/utils/library-operations.js b/app/frontend/js/utils/library-operations.js index df3036ed..259f7b38 100644 --- a/app/frontend/js/utils/library-operations.js +++ b/app/frontend/js/utils/library-operations.js @@ -535,9 +535,7 @@ export function removeFromQueue(Alpine, idSet) { } } } - if (queue._originalOrder) { - queue._originalOrder = queue._originalOrder.filter((t) => !idSet.has(t.id)); - } + // Original order is now managed by the backend } /** @@ -565,7 +563,6 @@ export function removeTracksLocallyOp(store, Alpine, trackIds) { const queue = Alpine.store('queue'); queue.items = []; - queue._originalOrder = []; queue.currentIndex = -1; Alpine.store('player').stop(); return; diff --git a/app/frontend/js/utils/queue-builder.js b/app/frontend/js/utils/queue-builder.js index ebc03c1b..baa7c93c 100644 --- a/app/frontend/js/utils/queue-builder.js +++ b/app/frontend/js/utils/queue-builder.js @@ -30,10 +30,7 @@ export async function handleDoubleClickPlay(ctx, track, allTracks, index, logPre // Apply response to queue store ctx.queue.items = result.items.map((item) => item.track || item); ctx.queue.currentIndex = result.current_index; - ctx.queue._originalOrder = [...ctx.queue.items]; - ctx.queue._playHistory = []; - ctx.queue._playNextOffset = 0; - ctx.queue._playNextTrackIds = new Set(); + ctx.queue.shuffle = result.shuffle_enabled ?? ctx.queue.shuffle; // Update player state from the track returned by backend ctx.player.updateTrackState(result.track, result.duration_ms); diff --git a/backlog/tasks/task-328 - Backend-queue-state-machine-replacing-frontend-shuffle-navigation-and-play-next-logic.md b/backlog/completed/task-328 - Backend-queue-state-machine-replacing-frontend-shuffle-navigation-and-play-next-logic.md similarity index 76% rename from backlog/tasks/task-328 - Backend-queue-state-machine-replacing-frontend-shuffle-navigation-and-play-next-logic.md rename to backlog/completed/task-328 - Backend-queue-state-machine-replacing-frontend-shuffle-navigation-and-play-next-logic.md index dcd76db8..0420ba90 100644 --- a/backlog/tasks/task-328 - Backend-queue-state-machine-replacing-frontend-shuffle-navigation-and-play-next-logic.md +++ b/backlog/completed/task-328 - Backend-queue-state-machine-replacing-frontend-shuffle-navigation-and-play-next-logic.md @@ -3,10 +3,10 @@ id: TASK-328 title: >- Backend queue state machine replacing frontend shuffle, navigation, and play-next logic -status: In Progress +status: Done assignee: [] created_date: '2026-04-13 03:19' -updated_date: '2026-04-13 04:00' +updated_date: '2026-04-13 17:18' labels: - backend - queue @@ -163,14 +163,39 @@ pub struct QueueStateSnapshot { ## Acceptance Criteria -- [ ] #1 Queue store's shuffle toggle calls a single invoke('queue_set_shuffle', { enabled }) and receives the reordered queue snapshot from the backend -- [ ] #2 Fisher-Yates shuffle and unshuffle (restore original order) logic is removed from app/frontend/js/stores/queue.js -- [ ] #3 Loop mode changes call invoke('queue_set_loop', { mode }) and receive the updated queue state including whether queue will wrap -- [ ] #4 playNext() and playPrevious() are backend commands that return the new current track and updated index — frontend no longer increments/decrements currentIndex locally -- [ ] #5 Play-next pinning (insertions at _playNextOffset) is handled by a backend command that maintains the offset and returns the updated items array -- [ ] #6 History tracking (_playHistory) moves to backend — playPrevious uses backend history instead of frontend array -- [ ] #7 Queue integrity check (detectAndRepairInconsistencies) runs in Rust via queue_check_integrity command and emits results -- [ ] #8 Frontend queue.js store is reduced to: event listeners for queue state snapshots, getters for computed UI properties, and invoke wrappers -- [ ] #9 Rust tests cover: shuffle/unshuffle preserves all tracks; shuffle puts current track at index 0; loop-all wraps to index 0; loop-one replays same index; play-next inserts at correct offset; playPrevious follows history stack; integrity check detects and repairs duplicate entries -- [ ] #10 Frontend Vitest tests verify queue store applies state snapshots from backend events correctly +- [x] #1 Queue store's shuffle toggle calls a single invoke('queue_set_shuffle', { enabled }) and receives the reordered queue snapshot from the backend +- [x] #2 Fisher-Yates shuffle and unshuffle (restore original order) logic is removed from app/frontend/js/stores/queue.js +- [x] #3 Loop mode changes call invoke('queue_set_loop', { mode }) and receive the updated queue state including whether queue will wrap +- [x] #4 playNext() and playPrevious() are backend commands that return the new current track and updated index — frontend no longer increments/decrements currentIndex locally +- [x] #5 Play-next pinning (insertions at _playNextOffset) is handled by a backend command that maintains the offset and returns the updated items array +- [x] #6 History tracking (_playHistory) moves to backend — playPrevious uses backend history instead of frontend array +- [x] #7 Queue integrity check (detectAndRepairInconsistencies) runs in Rust via queue_check_integrity command and emits results +- [x] #8 Frontend queue.js store is reduced to: event listeners for queue state snapshots, getters for computed UI properties, and invoke wrappers +- [x] #9 Rust tests cover: shuffle/unshuffle preserves all tracks; shuffle puts current track at index 0; loop-all wraps to index 0; loop-one replays same index; play-next inserts at correct offset; playPrevious follows history stack; integrity check detects and repairs duplicate entries +- [x] #10 Frontend Vitest tests verify queue store applies state snapshots from backend events correctly + +## Final Summary + + +Migrated ~500 lines of queue state machine logic from frontend JavaScript to Rust backend, making the frontend a thin reactive layer. + +### Backend (Rust) +- Extended `queue_state` table with 4 new columns: `play_next_offset`, `play_history_json`, `play_next_track_ids_json`, `repeat_one_pending` +- `toggle_shuffle()`: Fisher-Yates with current-track-at-0 and play-next pinning; unshuffle restores original order +- `add_play_next()`: move semantics, offset tracking, play_next_track_ids management +- `advance_to_next()`/`advance_to_previous()`: repeat-one two-phase, loop modes, history push/pop, reshuffle on loop restart +- `skip_to_next()`/`skip_to_previous()`: override repeat-one before delegating +- `check_integrity()`: duplicate detection, index bounds, orphaned play-next IDs with auto-repair +- 6 new Tauri commands registered: `queue_add_play_next`, `queue_play_next_track`, `queue_play_previous_track`, `queue_skip_next`, `queue_skip_previous`, `queue_check_integrity` +- `queue_set_shuffle` enhanced to return `QueueStateSnapshot` +- 773 Rust tests pass (69+ new) + +### Frontend (JavaScript) +- Removed: `_shuffleItems`, `_reshuffleForLoopRestart`, `_originalOrder`, `_playHistory`, `_playNextTrackIds`, `_playNextOffset`, `_repeatOnePending`, `_syncQueueToBackend`, `_validateQueueIntegrity`, `_pushToHistory`, `_popFromHistory` +- Added: `_applySnapshot()`, `_applyNavigationResult()` — apply backend state to local store +- All state-changing methods are now thin wrappers: call backend command, apply returned snapshot +- queue.js reduced from 892 to ~530 lines +- Updated queue-builder.test.js and queue.props.test.js to test backend-delegated pattern +- 444 frontend Vitest tests pass + diff --git a/crates/mt-tauri/gen/schemas/acl-manifests.json b/crates/mt-tauri/gen/schemas/acl-manifests.json index e4c13932..78de997e 100644 --- a/crates/mt-tauri/gen/schemas/acl-manifests.json +++ b/crates/mt-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"global-shortcut":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n","permissions":[]},"permissions":{"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-register-all":{"identifier":"allow-register-all","description":"Enables the register_all command without any pre-configured scope.","commands":{"allow":["register_all"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"allow-unregister-all":{"identifier":"allow-unregister-all","description":"Enables the unregister_all command without any pre-configured scope.","commands":{"allow":["unregister_all"],"deny":[]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-register-all":{"identifier":"deny-register-all","description":"Denies the register_all command without any pre-configured scope.","commands":{"allow":[],"deny":["register_all"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}},"deny-unregister-all":{"identifier":"deny-unregister-all","description":"Denies the unregister_all command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister_all"]}}},"permission_sets":{},"global_scope_schema":null},"mcp-bridge":{"default_permission":{"identifier":"default","description":"Default permissions for MCP Bridge plugin","permissions":["allow-capture-native-screenshot","allow-emit-event","allow-execute-command","allow-execute-js","allow-get-backend-state","allow-get-ipc-events","allow-get-window-info","allow-list-windows","allow-report-ipc-event","allow-request-script-injection","allow-script-result","allow-start-ipc-monitor","allow-stop-ipc-monitor"]},"permissions":{"allow-capture-native-screenshot":{"identifier":"allow-capture-native-screenshot","description":"Enables the capture_native_screenshot command without any pre-configured scope.","commands":{"allow":["capture_native_screenshot"],"deny":[]}},"allow-emit-event":{"identifier":"allow-emit-event","description":"Enables the emit_event command without any pre-configured scope.","commands":{"allow":["emit_event"],"deny":[]}},"allow-execute-command":{"identifier":"allow-execute-command","description":"Enables the execute_command command without any pre-configured scope.","commands":{"allow":["execute_command"],"deny":[]}},"allow-execute-js":{"identifier":"allow-execute-js","description":"Enables the execute_js command without any pre-configured scope.","commands":{"allow":["execute_js"],"deny":[]}},"allow-get-backend-state":{"identifier":"allow-get-backend-state","description":"Enables the get_backend_state command without any pre-configured scope.","commands":{"allow":["get_backend_state"],"deny":[]}},"allow-get-ipc-events":{"identifier":"allow-get-ipc-events","description":"Enables the get_ipc_events command without any pre-configured scope.","commands":{"allow":["get_ipc_events"],"deny":[]}},"allow-get-window-info":{"identifier":"allow-get-window-info","description":"Enables the get_window_info command without any pre-configured scope.","commands":{"allow":["get_window_info"],"deny":[]}},"allow-list-windows":{"identifier":"allow-list-windows","description":"Enables the list_windows command without any pre-configured scope.","commands":{"allow":["list_windows"],"deny":[]}},"allow-report-ipc-event":{"identifier":"allow-report-ipc-event","description":"Enables the report_ipc_event command without any pre-configured scope.","commands":{"allow":["report_ipc_event"],"deny":[]}},"allow-request-script-injection":{"identifier":"allow-request-script-injection","description":"Enables the request_script_injection command without any pre-configured scope.","commands":{"allow":["request_script_injection"],"deny":[]}},"allow-script-result":{"identifier":"allow-script-result","description":"Enables the script_result command without any pre-configured scope.","commands":{"allow":["script_result"],"deny":[]}},"allow-start-ipc-monitor":{"identifier":"allow-start-ipc-monitor","description":"Enables the start_ipc_monitor command without any pre-configured scope.","commands":{"allow":["start_ipc_monitor"],"deny":[]}},"allow-stop-ipc-monitor":{"identifier":"allow-stop-ipc-monitor","description":"Enables the stop_ipc_monitor command without any pre-configured scope.","commands":{"allow":["stop_ipc_monitor"],"deny":[]}},"deny-capture-native-screenshot":{"identifier":"deny-capture-native-screenshot","description":"Denies the capture_native_screenshot command without any pre-configured scope.","commands":{"allow":[],"deny":["capture_native_screenshot"]}},"deny-emit-event":{"identifier":"deny-emit-event","description":"Denies the emit_event command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_event"]}},"deny-execute-command":{"identifier":"deny-execute-command","description":"Denies the execute_command command without any pre-configured scope.","commands":{"allow":[],"deny":["execute_command"]}},"deny-execute-js":{"identifier":"deny-execute-js","description":"Denies the execute_js command without any pre-configured scope.","commands":{"allow":[],"deny":["execute_js"]}},"deny-get-backend-state":{"identifier":"deny-get-backend-state","description":"Denies the get_backend_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_backend_state"]}},"deny-get-ipc-events":{"identifier":"deny-get-ipc-events","description":"Denies the get_ipc_events command without any pre-configured scope.","commands":{"allow":[],"deny":["get_ipc_events"]}},"deny-get-window-info":{"identifier":"deny-get-window-info","description":"Denies the get_window_info command without any pre-configured scope.","commands":{"allow":[],"deny":["get_window_info"]}},"deny-list-windows":{"identifier":"deny-list-windows","description":"Denies the list_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["list_windows"]}},"deny-report-ipc-event":{"identifier":"deny-report-ipc-event","description":"Denies the report_ipc_event command without any pre-configured scope.","commands":{"allow":[],"deny":["report_ipc_event"]}},"deny-request-script-injection":{"identifier":"deny-request-script-injection","description":"Denies the request_script_injection command without any pre-configured scope.","commands":{"allow":[],"deny":["request_script_injection"]}},"deny-script-result":{"identifier":"deny-script-result","description":"Denies the script_result command without any pre-configured scope.","commands":{"allow":[],"deny":["script_result"]}},"deny-start-ipc-monitor":{"identifier":"deny-start-ipc-monitor","description":"Denies the start_ipc_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["start_ipc_monitor"]}},"deny-stop-ipc-monitor":{"identifier":"deny-stop-ipc-monitor","description":"Denies the stop_ipc_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_ipc_monitor"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}},"store":{"default_permission":{"identifier":"default","description":"This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n","permissions":["allow-load","allow-get-store","allow-set","allow-get","allow-has","allow-delete","allow-clear","allow-reset","allow-keys","allow-values","allow-entries","allow-length","allow-reload","allow-save"]},"permissions":{"allow-clear":{"identifier":"allow-clear","description":"Enables the clear command without any pre-configured scope.","commands":{"allow":["clear"],"deny":[]}},"allow-delete":{"identifier":"allow-delete","description":"Enables the delete command without any pre-configured scope.","commands":{"allow":["delete"],"deny":[]}},"allow-entries":{"identifier":"allow-entries","description":"Enables the entries command without any pre-configured scope.","commands":{"allow":["entries"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-get-store":{"identifier":"allow-get-store","description":"Enables the get_store command without any pre-configured scope.","commands":{"allow":["get_store"],"deny":[]}},"allow-has":{"identifier":"allow-has","description":"Enables the has command without any pre-configured scope.","commands":{"allow":["has"],"deny":[]}},"allow-keys":{"identifier":"allow-keys","description":"Enables the keys command without any pre-configured scope.","commands":{"allow":["keys"],"deny":[]}},"allow-length":{"identifier":"allow-length","description":"Enables the length command without any pre-configured scope.","commands":{"allow":["length"],"deny":[]}},"allow-load":{"identifier":"allow-load","description":"Enables the load command without any pre-configured scope.","commands":{"allow":["load"],"deny":[]}},"allow-reload":{"identifier":"allow-reload","description":"Enables the reload command without any pre-configured scope.","commands":{"allow":["reload"],"deny":[]}},"allow-reset":{"identifier":"allow-reset","description":"Enables the reset command without any pre-configured scope.","commands":{"allow":["reset"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"allow-set":{"identifier":"allow-set","description":"Enables the set command without any pre-configured scope.","commands":{"allow":["set"],"deny":[]}},"allow-values":{"identifier":"allow-values","description":"Enables the values command without any pre-configured scope.","commands":{"allow":["values"],"deny":[]}},"deny-clear":{"identifier":"deny-clear","description":"Denies the clear command without any pre-configured scope.","commands":{"allow":[],"deny":["clear"]}},"deny-delete":{"identifier":"deny-delete","description":"Denies the delete command without any pre-configured scope.","commands":{"allow":[],"deny":["delete"]}},"deny-entries":{"identifier":"deny-entries","description":"Denies the entries command without any pre-configured scope.","commands":{"allow":[],"deny":["entries"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-get-store":{"identifier":"deny-get-store","description":"Denies the get_store command without any pre-configured scope.","commands":{"allow":[],"deny":["get_store"]}},"deny-has":{"identifier":"deny-has","description":"Denies the has command without any pre-configured scope.","commands":{"allow":[],"deny":["has"]}},"deny-keys":{"identifier":"deny-keys","description":"Denies the keys command without any pre-configured scope.","commands":{"allow":[],"deny":["keys"]}},"deny-length":{"identifier":"deny-length","description":"Denies the length command without any pre-configured scope.","commands":{"allow":[],"deny":["length"]}},"deny-load":{"identifier":"deny-load","description":"Denies the load command without any pre-configured scope.","commands":{"allow":[],"deny":["load"]}},"deny-reload":{"identifier":"deny-reload","description":"Denies the reload command without any pre-configured scope.","commands":{"allow":[],"deny":["reload"]}},"deny-reset":{"identifier":"deny-reset","description":"Denies the reset command without any pre-configured scope.","commands":{"allow":[],"deny":["reset"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}},"deny-set":{"identifier":"deny-set","description":"Denies the set command without any pre-configured scope.","commands":{"allow":[],"deny":["set"]}},"deny-values":{"identifier":"deny-values","description":"Denies the values command without any pre-configured scope.","commands":{"allow":[],"deny":["values"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"global-shortcut":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n","permissions":[]},"permissions":{"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-register-all":{"identifier":"allow-register-all","description":"Enables the register_all command without any pre-configured scope.","commands":{"allow":["register_all"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"allow-unregister-all":{"identifier":"allow-unregister-all","description":"Enables the unregister_all command without any pre-configured scope.","commands":{"allow":["unregister_all"],"deny":[]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-register-all":{"identifier":"deny-register-all","description":"Denies the register_all command without any pre-configured scope.","commands":{"allow":[],"deny":["register_all"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}},"deny-unregister-all":{"identifier":"deny-unregister-all","description":"Denies the unregister_all command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister_all"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}},"store":{"default_permission":{"identifier":"default","description":"This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n","permissions":["allow-load","allow-get-store","allow-set","allow-get","allow-has","allow-delete","allow-clear","allow-reset","allow-keys","allow-values","allow-entries","allow-length","allow-reload","allow-save"]},"permissions":{"allow-clear":{"identifier":"allow-clear","description":"Enables the clear command without any pre-configured scope.","commands":{"allow":["clear"],"deny":[]}},"allow-delete":{"identifier":"allow-delete","description":"Enables the delete command without any pre-configured scope.","commands":{"allow":["delete"],"deny":[]}},"allow-entries":{"identifier":"allow-entries","description":"Enables the entries command without any pre-configured scope.","commands":{"allow":["entries"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-get-store":{"identifier":"allow-get-store","description":"Enables the get_store command without any pre-configured scope.","commands":{"allow":["get_store"],"deny":[]}},"allow-has":{"identifier":"allow-has","description":"Enables the has command without any pre-configured scope.","commands":{"allow":["has"],"deny":[]}},"allow-keys":{"identifier":"allow-keys","description":"Enables the keys command without any pre-configured scope.","commands":{"allow":["keys"],"deny":[]}},"allow-length":{"identifier":"allow-length","description":"Enables the length command without any pre-configured scope.","commands":{"allow":["length"],"deny":[]}},"allow-load":{"identifier":"allow-load","description":"Enables the load command without any pre-configured scope.","commands":{"allow":["load"],"deny":[]}},"allow-reload":{"identifier":"allow-reload","description":"Enables the reload command without any pre-configured scope.","commands":{"allow":["reload"],"deny":[]}},"allow-reset":{"identifier":"allow-reset","description":"Enables the reset command without any pre-configured scope.","commands":{"allow":["reset"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"allow-set":{"identifier":"allow-set","description":"Enables the set command without any pre-configured scope.","commands":{"allow":["set"],"deny":[]}},"allow-values":{"identifier":"allow-values","description":"Enables the values command without any pre-configured scope.","commands":{"allow":["values"],"deny":[]}},"deny-clear":{"identifier":"deny-clear","description":"Denies the clear command without any pre-configured scope.","commands":{"allow":[],"deny":["clear"]}},"deny-delete":{"identifier":"deny-delete","description":"Denies the delete command without any pre-configured scope.","commands":{"allow":[],"deny":["delete"]}},"deny-entries":{"identifier":"deny-entries","description":"Denies the entries command without any pre-configured scope.","commands":{"allow":[],"deny":["entries"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-get-store":{"identifier":"deny-get-store","description":"Denies the get_store command without any pre-configured scope.","commands":{"allow":[],"deny":["get_store"]}},"deny-has":{"identifier":"deny-has","description":"Denies the has command without any pre-configured scope.","commands":{"allow":[],"deny":["has"]}},"deny-keys":{"identifier":"deny-keys","description":"Denies the keys command without any pre-configured scope.","commands":{"allow":[],"deny":["keys"]}},"deny-length":{"identifier":"deny-length","description":"Denies the length command without any pre-configured scope.","commands":{"allow":[],"deny":["length"]}},"deny-load":{"identifier":"deny-load","description":"Denies the load command without any pre-configured scope.","commands":{"allow":[],"deny":["load"]}},"deny-reload":{"identifier":"deny-reload","description":"Denies the reload command without any pre-configured scope.","commands":{"allow":[],"deny":["reload"]}},"deny-reset":{"identifier":"deny-reset","description":"Denies the reset command without any pre-configured scope.","commands":{"allow":[],"deny":["reset"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}},"deny-set":{"identifier":"deny-set","description":"Denies the set command without any pre-configured scope.","commands":{"allow":[],"deny":["set"]}},"deny-values":{"identifier":"deny-values","description":"Denies the values command without any pre-configured scope.","commands":{"allow":[],"deny":["values"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/crates/mt-tauri/gen/schemas/desktop-schema.json b/crates/mt-tauri/gen/schemas/desktop-schema.json index 780e054c..e7f11fbb 100644 --- a/crates/mt-tauri/gen/schemas/desktop-schema.json +++ b/crates/mt-tauri/gen/schemas/desktop-schema.json @@ -2654,168 +2654,6 @@ "const": "global-shortcut:deny-unregister-all", "markdownDescription": "Denies the unregister_all command without any pre-configured scope." }, - { - "description": "Default permissions for MCP Bridge plugin\n#### This default permission set includes:\n\n- `allow-capture-native-screenshot`\n- `allow-emit-event`\n- `allow-execute-command`\n- `allow-execute-js`\n- `allow-get-backend-state`\n- `allow-get-ipc-events`\n- `allow-get-window-info`\n- `allow-list-windows`\n- `allow-report-ipc-event`\n- `allow-request-script-injection`\n- `allow-script-result`\n- `allow-start-ipc-monitor`\n- `allow-stop-ipc-monitor`", - "type": "string", - "const": "mcp-bridge:default", - "markdownDescription": "Default permissions for MCP Bridge plugin\n#### This default permission set includes:\n\n- `allow-capture-native-screenshot`\n- `allow-emit-event`\n- `allow-execute-command`\n- `allow-execute-js`\n- `allow-get-backend-state`\n- `allow-get-ipc-events`\n- `allow-get-window-info`\n- `allow-list-windows`\n- `allow-report-ipc-event`\n- `allow-request-script-injection`\n- `allow-script-result`\n- `allow-start-ipc-monitor`\n- `allow-stop-ipc-monitor`" - }, - { - "description": "Enables the capture_native_screenshot command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-capture-native-screenshot", - "markdownDescription": "Enables the capture_native_screenshot command without any pre-configured scope." - }, - { - "description": "Enables the emit_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-emit-event", - "markdownDescription": "Enables the emit_event command without any pre-configured scope." - }, - { - "description": "Enables the execute_command command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-execute-command", - "markdownDescription": "Enables the execute_command command without any pre-configured scope." - }, - { - "description": "Enables the execute_js command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-execute-js", - "markdownDescription": "Enables the execute_js command without any pre-configured scope." - }, - { - "description": "Enables the get_backend_state command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-get-backend-state", - "markdownDescription": "Enables the get_backend_state command without any pre-configured scope." - }, - { - "description": "Enables the get_ipc_events command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-get-ipc-events", - "markdownDescription": "Enables the get_ipc_events command without any pre-configured scope." - }, - { - "description": "Enables the get_window_info command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-get-window-info", - "markdownDescription": "Enables the get_window_info command without any pre-configured scope." - }, - { - "description": "Enables the list_windows command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-list-windows", - "markdownDescription": "Enables the list_windows command without any pre-configured scope." - }, - { - "description": "Enables the report_ipc_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-report-ipc-event", - "markdownDescription": "Enables the report_ipc_event command without any pre-configured scope." - }, - { - "description": "Enables the request_script_injection command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-request-script-injection", - "markdownDescription": "Enables the request_script_injection command without any pre-configured scope." - }, - { - "description": "Enables the script_result command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-script-result", - "markdownDescription": "Enables the script_result command without any pre-configured scope." - }, - { - "description": "Enables the start_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-start-ipc-monitor", - "markdownDescription": "Enables the start_ipc_monitor command without any pre-configured scope." - }, - { - "description": "Enables the stop_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-stop-ipc-monitor", - "markdownDescription": "Enables the stop_ipc_monitor command without any pre-configured scope." - }, - { - "description": "Denies the capture_native_screenshot command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-capture-native-screenshot", - "markdownDescription": "Denies the capture_native_screenshot command without any pre-configured scope." - }, - { - "description": "Denies the emit_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-emit-event", - "markdownDescription": "Denies the emit_event command without any pre-configured scope." - }, - { - "description": "Denies the execute_command command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-execute-command", - "markdownDescription": "Denies the execute_command command without any pre-configured scope." - }, - { - "description": "Denies the execute_js command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-execute-js", - "markdownDescription": "Denies the execute_js command without any pre-configured scope." - }, - { - "description": "Denies the get_backend_state command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-get-backend-state", - "markdownDescription": "Denies the get_backend_state command without any pre-configured scope." - }, - { - "description": "Denies the get_ipc_events command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-get-ipc-events", - "markdownDescription": "Denies the get_ipc_events command without any pre-configured scope." - }, - { - "description": "Denies the get_window_info command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-get-window-info", - "markdownDescription": "Denies the get_window_info command without any pre-configured scope." - }, - { - "description": "Denies the list_windows command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-list-windows", - "markdownDescription": "Denies the list_windows command without any pre-configured scope." - }, - { - "description": "Denies the report_ipc_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-report-ipc-event", - "markdownDescription": "Denies the report_ipc_event command without any pre-configured scope." - }, - { - "description": "Denies the request_script_injection command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-request-script-injection", - "markdownDescription": "Denies the request_script_injection command without any pre-configured scope." - }, - { - "description": "Denies the script_result command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-script-result", - "markdownDescription": "Denies the script_result command without any pre-configured scope." - }, - { - "description": "Denies the start_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-start-ipc-monitor", - "markdownDescription": "Denies the start_ipc_monitor command without any pre-configured scope." - }, - { - "description": "Denies the stop_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-stop-ipc-monitor", - "markdownDescription": "Denies the stop_ipc_monitor command without any pre-configured scope." - }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", diff --git a/crates/mt-tauri/gen/schemas/macOS-schema.json b/crates/mt-tauri/gen/schemas/macOS-schema.json index 780e054c..e7f11fbb 100644 --- a/crates/mt-tauri/gen/schemas/macOS-schema.json +++ b/crates/mt-tauri/gen/schemas/macOS-schema.json @@ -2654,168 +2654,6 @@ "const": "global-shortcut:deny-unregister-all", "markdownDescription": "Denies the unregister_all command without any pre-configured scope." }, - { - "description": "Default permissions for MCP Bridge plugin\n#### This default permission set includes:\n\n- `allow-capture-native-screenshot`\n- `allow-emit-event`\n- `allow-execute-command`\n- `allow-execute-js`\n- `allow-get-backend-state`\n- `allow-get-ipc-events`\n- `allow-get-window-info`\n- `allow-list-windows`\n- `allow-report-ipc-event`\n- `allow-request-script-injection`\n- `allow-script-result`\n- `allow-start-ipc-monitor`\n- `allow-stop-ipc-monitor`", - "type": "string", - "const": "mcp-bridge:default", - "markdownDescription": "Default permissions for MCP Bridge plugin\n#### This default permission set includes:\n\n- `allow-capture-native-screenshot`\n- `allow-emit-event`\n- `allow-execute-command`\n- `allow-execute-js`\n- `allow-get-backend-state`\n- `allow-get-ipc-events`\n- `allow-get-window-info`\n- `allow-list-windows`\n- `allow-report-ipc-event`\n- `allow-request-script-injection`\n- `allow-script-result`\n- `allow-start-ipc-monitor`\n- `allow-stop-ipc-monitor`" - }, - { - "description": "Enables the capture_native_screenshot command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-capture-native-screenshot", - "markdownDescription": "Enables the capture_native_screenshot command without any pre-configured scope." - }, - { - "description": "Enables the emit_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-emit-event", - "markdownDescription": "Enables the emit_event command without any pre-configured scope." - }, - { - "description": "Enables the execute_command command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-execute-command", - "markdownDescription": "Enables the execute_command command without any pre-configured scope." - }, - { - "description": "Enables the execute_js command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-execute-js", - "markdownDescription": "Enables the execute_js command without any pre-configured scope." - }, - { - "description": "Enables the get_backend_state command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-get-backend-state", - "markdownDescription": "Enables the get_backend_state command without any pre-configured scope." - }, - { - "description": "Enables the get_ipc_events command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-get-ipc-events", - "markdownDescription": "Enables the get_ipc_events command without any pre-configured scope." - }, - { - "description": "Enables the get_window_info command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-get-window-info", - "markdownDescription": "Enables the get_window_info command without any pre-configured scope." - }, - { - "description": "Enables the list_windows command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-list-windows", - "markdownDescription": "Enables the list_windows command without any pre-configured scope." - }, - { - "description": "Enables the report_ipc_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-report-ipc-event", - "markdownDescription": "Enables the report_ipc_event command without any pre-configured scope." - }, - { - "description": "Enables the request_script_injection command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-request-script-injection", - "markdownDescription": "Enables the request_script_injection command without any pre-configured scope." - }, - { - "description": "Enables the script_result command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-script-result", - "markdownDescription": "Enables the script_result command without any pre-configured scope." - }, - { - "description": "Enables the start_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-start-ipc-monitor", - "markdownDescription": "Enables the start_ipc_monitor command without any pre-configured scope." - }, - { - "description": "Enables the stop_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:allow-stop-ipc-monitor", - "markdownDescription": "Enables the stop_ipc_monitor command without any pre-configured scope." - }, - { - "description": "Denies the capture_native_screenshot command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-capture-native-screenshot", - "markdownDescription": "Denies the capture_native_screenshot command without any pre-configured scope." - }, - { - "description": "Denies the emit_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-emit-event", - "markdownDescription": "Denies the emit_event command without any pre-configured scope." - }, - { - "description": "Denies the execute_command command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-execute-command", - "markdownDescription": "Denies the execute_command command without any pre-configured scope." - }, - { - "description": "Denies the execute_js command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-execute-js", - "markdownDescription": "Denies the execute_js command without any pre-configured scope." - }, - { - "description": "Denies the get_backend_state command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-get-backend-state", - "markdownDescription": "Denies the get_backend_state command without any pre-configured scope." - }, - { - "description": "Denies the get_ipc_events command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-get-ipc-events", - "markdownDescription": "Denies the get_ipc_events command without any pre-configured scope." - }, - { - "description": "Denies the get_window_info command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-get-window-info", - "markdownDescription": "Denies the get_window_info command without any pre-configured scope." - }, - { - "description": "Denies the list_windows command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-list-windows", - "markdownDescription": "Denies the list_windows command without any pre-configured scope." - }, - { - "description": "Denies the report_ipc_event command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-report-ipc-event", - "markdownDescription": "Denies the report_ipc_event command without any pre-configured scope." - }, - { - "description": "Denies the request_script_injection command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-request-script-injection", - "markdownDescription": "Denies the request_script_injection command without any pre-configured scope." - }, - { - "description": "Denies the script_result command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-script-result", - "markdownDescription": "Denies the script_result command without any pre-configured scope." - }, - { - "description": "Denies the start_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-start-ipc-monitor", - "markdownDescription": "Denies the start_ipc_monitor command without any pre-configured scope." - }, - { - "description": "Denies the stop_ipc_monitor command without any pre-configured scope.", - "type": "string", - "const": "mcp-bridge:deny-stop-ipc-monitor", - "markdownDescription": "Denies the stop_ipc_monitor command without any pre-configured scope." - }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", diff --git a/crates/mt-tauri/src/commands/mod.rs b/crates/mt-tauri/src/commands/mod.rs index ca48d65b..14722b71 100644 --- a/crates/mt-tauri/src/commands/mod.rs +++ b/crates/mt-tauri/src/commands/mod.rs @@ -33,9 +33,10 @@ pub(crate) use playlists::{ }; pub(crate) use queue::{ - queue_add, queue_add_files, queue_clear, queue_get, queue_get_playback_state, - queue_play_context, queue_remove, queue_reorder, queue_set_current_index, queue_set_loop, - queue_set_shuffle, queue_shuffle, + queue_add, queue_add_files, queue_add_play_next, queue_check_integrity, queue_clear, queue_get, + queue_get_playback_state, queue_play_context, queue_play_next_track, queue_play_previous_track, + queue_remove, queue_reorder, queue_set_current_index, queue_set_loop, queue_set_shuffle, + queue_shuffle, queue_skip_next, queue_skip_previous, }; pub(crate) use lyrics::{lyrics_clear_cache, lyrics_get}; diff --git a/crates/mt-tauri/src/commands/queue.rs b/crates/mt-tauri/src/commands/queue.rs index 26338e1f..1f46fcb5 100644 --- a/crates/mt-tauri/src/commands/queue.rs +++ b/crates/mt-tauri/src/commands/queue.rs @@ -41,6 +41,26 @@ pub struct QueueOperationResponse { pub queue_length: i64, } +/// Full queue state snapshot returned by state-changing commands +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct QueueStateSnapshot { + pub items: Vec, + pub current_index: i64, + pub shuffle_enabled: bool, + pub loop_mode: String, + pub play_next_offset: i64, +} + +/// Result of a navigation command (play next/previous/skip) +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct QueueNavigationResult { + /// "play", "stop", or "seek_zero" + pub action: String, + pub track: Option, + pub duration_ms: Option, + pub snapshot: QueueStateSnapshot, +} + /// Get the current playback queue with track metadata #[tracing::instrument(skip(db))] #[tauri::command] @@ -271,26 +291,36 @@ pub(crate) fn queue_set_current_index( Ok(()) } -/// Set shuffle enabled in queue playback state +/// Toggle shuffle on/off with full state machine: saves/restores original order, +/// pins current track at index 0 and play-next tracks after it when enabling. #[tracing::instrument(skip(app, db))] #[tauri::command] pub(crate) fn queue_set_shuffle( app: AppHandle, db: State<'_, Database>, enabled: bool, -) -> Result<(), String> { +) -> Result { let conn = db.conn().map_err(|e| e.to_string())?; - queue::set_shuffle_enabled(&conn, enabled).map_err(|e| e.to_string())?; + let items = queue::toggle_shuffle(&conn, enabled).map_err(|e| e.to_string())?; - // Emit state changed event let state = queue::get_queue_state(&conn).map_err(|e| e.to_string())?; + + // Emit events + let queue_length = items.len() as i64; + let _ = app.emit_queue_updated(QueueUpdatedEvent::shuffled(queue_length)); let _ = app.emit_queue_state_changed(QueueStateChangedEvent::new( state.current_index, state.shuffle_enabled, - state.loop_mode, + state.loop_mode.clone(), )); - Ok(()) + Ok(QueueStateSnapshot { + items, + current_index: state.current_index, + shuffle_enabled: state.shuffle_enabled, + loop_mode: state.loop_mode, + play_next_offset: state.play_next_offset, + }) } /// Set loop mode in queue playback state @@ -315,6 +345,248 @@ pub(crate) fn queue_set_loop( Ok(()) } +/// Add tracks as "play next" with move semantics and offset tracking +#[tracing::instrument(skip(app, db))] +#[tauri::command] +pub(crate) fn queue_add_play_next( + app: AppHandle, + db: State<'_, Database>, + track_ids: Vec, +) -> Result { + let conn = db.conn().map_err(|e| e.to_string())?; + let items = queue::add_play_next(&conn, &track_ids).map_err(|e| e.to_string())?; + + let state = queue::get_queue_state(&conn).map_err(|e| e.to_string())?; + + let queue_length = items.len() as i64; + let _ = app.emit_queue_updated(QueueUpdatedEvent::added( + (state.current_index + 1..state.current_index + 1 + track_ids.len() as i64).collect(), + queue_length, + )); + let _ = app.emit_queue_state_changed(QueueStateChangedEvent::new( + state.current_index, + state.shuffle_enabled, + state.loop_mode.clone(), + )); + + Ok(QueueStateSnapshot { + items, + current_index: state.current_index, + shuffle_enabled: state.shuffle_enabled, + loop_mode: state.loop_mode, + play_next_offset: state.play_next_offset, + }) +} + +/// Helper to build a QueueNavigationResult from a navigation action +fn build_navigation_result( + action: &queue::NavigationAction, + items: &[QueueItem], + state: &QueueState, + duration_ms: Option, +) -> QueueNavigationResult { + let (action_str, track) = match action { + queue::NavigationAction::Play(idx) => { + let track = items.get(*idx).map(|item| item.track.clone()); + ("play".to_string(), track) + } + queue::NavigationAction::Stop => ("stop".to_string(), None), + queue::NavigationAction::SeekZero => { + let track = if state.current_index >= 0 { + items + .get(state.current_index as usize) + .map(|item| item.track.clone()) + } else { + None + }; + ("seek_zero".to_string(), track) + } + }; + + QueueNavigationResult { + action: action_str, + track, + duration_ms, + snapshot: QueueStateSnapshot { + items: items.to_vec(), + current_index: state.current_index, + shuffle_enabled: state.shuffle_enabled, + loop_mode: state.loop_mode.clone(), + play_next_offset: state.play_next_offset, + }, + } +} + +/// Play the next track in the queue with full state machine logic +#[tracing::instrument(skip(app, db, audio, cache))] +#[tauri::command] +pub(crate) fn queue_play_next_track( + app: AppHandle, + db: State<'_, Database>, + audio: State<'_, AudioState>, + cache: State<'_, NetworkFileCache>, +) -> Result { + let conn = db.conn().map_err(|e| e.to_string())?; + let (action, items) = queue::advance_to_next(&conn).map_err(|e| e.to_string())?; + + let duration_ms = if let queue::NavigationAction::Play(idx) = &action { + if let Some(item) = items.get(*idx) { + let info = + audio.load_and_play(&item.track.filepath, Some(item.track.id), &cache, &app)?; + Some(info.duration_ms) + } else { + None + } + } else { + None + }; + + let state = queue::get_queue_state(&conn).map_err(|e| e.to_string())?; + let _ = app.emit_queue_state_changed(QueueStateChangedEvent::new( + state.current_index, + state.shuffle_enabled, + state.loop_mode.clone(), + )); + + Ok(build_navigation_result( + &action, + &items, + &state, + duration_ms, + )) +} + +/// Play the previous track in the queue +#[tracing::instrument(skip(app, db, audio, cache))] +#[tauri::command] +pub(crate) fn queue_play_previous_track( + app: AppHandle, + db: State<'_, Database>, + audio: State<'_, AudioState>, + cache: State<'_, NetworkFileCache>, + current_time_ms: u64, +) -> Result { + let conn = db.conn().map_err(|e| e.to_string())?; + let (action, items) = + queue::advance_to_previous(&conn, current_time_ms).map_err(|e| e.to_string())?; + + let duration_ms = if let queue::NavigationAction::Play(idx) = &action { + if let Some(item) = items.get(*idx) { + let info = + audio.load_and_play(&item.track.filepath, Some(item.track.id), &cache, &app)?; + Some(info.duration_ms) + } else { + None + } + } else { + None + }; + + let state = queue::get_queue_state(&conn).map_err(|e| e.to_string())?; + let _ = app.emit_queue_state_changed(QueueStateChangedEvent::new( + state.current_index, + state.shuffle_enabled, + state.loop_mode.clone(), + )); + + Ok(build_navigation_result( + &action, + &items, + &state, + duration_ms, + )) +} + +/// Skip to next track, overriding repeat-one mode +#[tracing::instrument(skip(app, db, audio, cache))] +#[tauri::command] +pub(crate) fn queue_skip_next( + app: AppHandle, + db: State<'_, Database>, + audio: State<'_, AudioState>, + cache: State<'_, NetworkFileCache>, +) -> Result { + let conn = db.conn().map_err(|e| e.to_string())?; + let (action, items) = queue::skip_to_next(&conn).map_err(|e| e.to_string())?; + + let duration_ms = if let queue::NavigationAction::Play(idx) = &action { + if let Some(item) = items.get(*idx) { + let info = + audio.load_and_play(&item.track.filepath, Some(item.track.id), &cache, &app)?; + Some(info.duration_ms) + } else { + None + } + } else { + None + }; + + let state = queue::get_queue_state(&conn).map_err(|e| e.to_string())?; + let _ = app.emit_queue_state_changed(QueueStateChangedEvent::new( + state.current_index, + state.shuffle_enabled, + state.loop_mode.clone(), + )); + + Ok(build_navigation_result( + &action, + &items, + &state, + duration_ms, + )) +} + +/// Skip to previous track, overriding repeat-one mode +#[tracing::instrument(skip(app, db, audio, cache))] +#[tauri::command] +pub(crate) fn queue_skip_previous( + app: AppHandle, + db: State<'_, Database>, + audio: State<'_, AudioState>, + cache: State<'_, NetworkFileCache>, + current_time_ms: u64, +) -> Result { + let conn = db.conn().map_err(|e| e.to_string())?; + let (action, items) = + queue::skip_to_previous(&conn, current_time_ms).map_err(|e| e.to_string())?; + + let duration_ms = if let queue::NavigationAction::Play(idx) = &action { + if let Some(item) = items.get(*idx) { + let info = + audio.load_and_play(&item.track.filepath, Some(item.track.id), &cache, &app)?; + Some(info.duration_ms) + } else { + None + } + } else { + None + }; + + let state = queue::get_queue_state(&conn).map_err(|e| e.to_string())?; + let _ = app.emit_queue_state_changed(QueueStateChangedEvent::new( + state.current_index, + state.shuffle_enabled, + state.loop_mode.clone(), + )); + + Ok(build_navigation_result( + &action, + &items, + &state, + duration_ms, + )) +} + +/// Check queue integrity and repair issues +#[tracing::instrument(skip(db))] +#[tauri::command] +pub(crate) fn queue_check_integrity( + db: State<'_, Database>, +) -> Result { + let conn = db.conn().map_err(|e| e.to_string())?; + queue::check_integrity(&conn).map_err(|e| e.to_string()) +} + /// Response for atomic play-context operations #[derive(Clone, serde::Serialize, serde::Deserialize)] pub struct PlayContextResponse { diff --git a/crates/mt-tauri/src/db/models.rs b/crates/mt-tauri/src/db/models.rs index fa155cec..e7a32746 100644 --- a/crates/mt-tauri/src/db/models.rs +++ b/crates/mt-tauri/src/db/models.rs @@ -68,6 +68,10 @@ pub struct QueueState { pub shuffle_enabled: bool, pub loop_mode: String, pub original_order_json: Option, + pub play_next_offset: i64, + pub play_history_json: Option, + pub play_next_track_ids_json: Option, + pub repeat_one_pending: bool, } /// Playlist metadata diff --git a/crates/mt-tauri/src/db/queue.rs b/crates/mt-tauri/src/db/queue.rs index 62a421e6..eb0600a6 100644 --- a/crates/mt-tauri/src/db/queue.rs +++ b/crates/mt-tauri/src/db/queue.rs @@ -265,7 +265,8 @@ pub(crate) fn get_queue_length(conn: &Connection) -> DbResult { /// Get queue playback state pub(crate) fn get_queue_state(conn: &Connection) -> DbResult { let result = conn.query_row( - "SELECT current_index, shuffle_enabled, loop_mode, original_order_json + "SELECT current_index, shuffle_enabled, loop_mode, original_order_json, + play_next_offset, play_history_json, play_next_track_ids_json, repeat_one_pending FROM queue_state WHERE id = 1", [], |row| { @@ -274,6 +275,10 @@ pub(crate) fn get_queue_state(conn: &Connection) -> DbResult { shuffle_enabled: row.get::<_, i64>(1)? != 0, loop_mode: row.get(2)?, original_order_json: row.get(3)?, + play_next_offset: row.get::<_, Option>(4)?.unwrap_or(0), + play_history_json: row.get(5)?, + play_next_track_ids_json: row.get(6)?, + repeat_one_pending: row.get::<_, Option>(7)?.unwrap_or(0) != 0, }) }, ); @@ -287,6 +292,10 @@ pub(crate) fn get_queue_state(conn: &Connection) -> DbResult { shuffle_enabled: false, loop_mode: "none".to_string(), original_order_json: None, + play_next_offset: 0, + play_history_json: None, + play_next_track_ids_json: None, + repeat_one_pending: false, }; set_queue_state(conn, &default_state)?; Ok(default_state) @@ -298,13 +307,19 @@ pub(crate) fn get_queue_state(conn: &Connection) -> DbResult { /// Set queue playback state pub(crate) fn set_queue_state(conn: &Connection, state: &QueueState) -> DbResult<()> { conn.execute( - "INSERT OR REPLACE INTO queue_state (id, current_index, shuffle_enabled, loop_mode, original_order_json) - VALUES (1, ?, ?, ?, ?)", + "INSERT OR REPLACE INTO queue_state (id, current_index, shuffle_enabled, loop_mode, + original_order_json, play_next_offset, play_history_json, play_next_track_ids_json, + repeat_one_pending) + VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)", params![ state.current_index, if state.shuffle_enabled { 1 } else { 0 }, &state.loop_mode, - &state.original_order_json + &state.original_order_json, + state.play_next_offset, + &state.play_history_json, + &state.play_next_track_ids_json, + if state.repeat_one_pending { 1 } else { 0 }, ], )?; Ok(()) @@ -359,6 +374,644 @@ pub(crate) fn set_original_order_json(conn: &Connection, json: Option) - Ok(()) } +/// Update play_next_offset in queue state +pub(crate) fn set_play_next_offset(conn: &Connection, offset: i64) -> DbResult<()> { + let _ = get_queue_state(conn)?; + conn.execute( + "UPDATE queue_state SET play_next_offset = ? WHERE id = 1", + params![offset], + )?; + Ok(()) +} + +/// Update play history JSON in queue state +pub(crate) fn set_play_history_json(conn: &Connection, json: Option) -> DbResult<()> { + let _ = get_queue_state(conn)?; + conn.execute( + "UPDATE queue_state SET play_history_json = ? WHERE id = 1", + params![json], + )?; + Ok(()) +} + +/// Update play-next track IDs JSON in queue state +pub(crate) fn set_play_next_track_ids_json( + conn: &Connection, + json: Option, +) -> DbResult<()> { + let _ = get_queue_state(conn)?; + conn.execute( + "UPDATE queue_state SET play_next_track_ids_json = ? WHERE id = 1", + params![json], + )?; + Ok(()) +} + +/// Update repeat_one_pending flag in queue state +pub(crate) fn set_repeat_one_pending(conn: &Connection, pending: bool) -> DbResult<()> { + let _ = get_queue_state(conn)?; + conn.execute( + "UPDATE queue_state SET repeat_one_pending = ? WHERE id = 1", + params![if pending { 1 } else { 0 }], + )?; + Ok(()) +} + +/// Add tracks as "play next" after the current track + any existing play-next tracks. +/// +/// Move semantics: if a track already exists in the queue (and is not the current track), +/// it is removed first. Tracks are inserted at `current_index + 1 + play_next_offset`. +pub(crate) fn add_play_next(conn: &Connection, track_ids: &[i64]) -> DbResult> { + if track_ids.is_empty() { + return get_queue(conn); + } + + let state = get_queue_state(conn)?; + let items = get_queue(conn)?; + + if items.is_empty() { + // Nothing playing — just add to end + add_to_queue(conn, track_ids, None)?; + return get_queue(conn); + } + + // Resolve filepaths for the track IDs + let placeholders = track_ids.iter().map(|_| "?").collect::>().join(","); + let sql = format!( + "SELECT id, filepath FROM library WHERE id IN ({})", + placeholders + ); + let mut stmt = conn.prepare(&sql)?; + let params: Vec<&dyn rusqlite::ToSql> = track_ids + .iter() + .map(|id| id as &dyn rusqlite::ToSql) + .collect(); + let track_map: std::collections::HashMap = stmt + .query_map(params.as_slice(), |row| Ok((row.get(0)?, row.get(1)?)))? + .filter_map(|r| r.ok()) + .collect(); + drop(stmt); + + let current_idx = state.current_index.max(0) as usize; + let current_track_id = items.get(current_idx).map(|i| i.track.id); + + // Build new queue: remove tracks that are being moved (except current), then insert at position + let track_id_set: std::collections::HashSet = track_ids.iter().copied().collect(); + + // Tracks to insert + let new_fps: Vec = track_ids + .iter() + .filter_map(|id| track_map.get(id).cloned()) + .collect(); + + // Count how many tracks before current_idx will be removed (affects current_idx) + let removed_before_current: usize = items + .iter() + .enumerate() + .filter(|(i, item)| { + *i < current_idx + && track_id_set.contains(&item.track.id) + && Some(item.track.id) != current_track_id + }) + .count(); + + let adjusted_current_idx = current_idx - removed_before_current; + let insert_pos = adjusted_current_idx + 1 + state.play_next_offset as usize; + + // Build filepath list excluding tracks being moved (except current) + let mut kept: Vec = Vec::new(); + for (i, item) in items.iter().enumerate() { + if track_id_set.contains(&item.track.id) + && Some(item.track.id) != current_track_id + && i != current_idx + { + continue; // Skip — will be re-inserted at play-next position + } + kept.push(item.track.filepath.clone()); + } + + // Insert at position + let insert_at = insert_pos.min(kept.len()); + for (j, fp) in new_fps.iter().enumerate() { + kept.insert(insert_at + j, fp.clone()); + } + // Rebuild queue + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM queue", [])?; + for fp in &kept { + tx.execute("INSERT INTO queue (filepath) VALUES (?)", params![fp])?; + } + + // Update state: increment play_next_offset, add to play_next_track_ids + let new_offset = state.play_next_offset + new_fps.len() as i64; + let mut play_next_ids: Vec = state + .play_next_track_ids_json + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + for id in track_ids { + if !play_next_ids.contains(id) { + play_next_ids.push(*id); + } + } + let play_next_json = serde_json::to_string(&play_next_ids).ok(); + + tx.execute( + "UPDATE queue_state SET play_next_offset = ?, play_next_track_ids_json = ?, + current_index = ? WHERE id = 1", + params![new_offset, play_next_json, adjusted_current_idx as i64], + )?; + + tx.commit()?; + get_queue(conn) +} + +/// Toggle shuffle on/off with full state management. +/// +/// Enable: saves original order, separates current + play-next + regular tracks, +/// Fisher-Yates shuffles regular tracks, rebuilds queue as [current, play-next..., shuffled...]. +/// Disable: restores original order from saved JSON, finds current track in restored order. +pub(crate) fn toggle_shuffle(conn: &Connection, enabled: bool) -> DbResult> { + use rand::rng; + use rand::seq::SliceRandom; + + let state = get_queue_state(conn)?; + let items = get_queue(conn)?; + + if items.is_empty() { + set_shuffle_enabled(conn, enabled)?; + return Ok(items); + } + + let tx = conn.unchecked_transaction()?; + + if enabled { + // Save current order for unshuffle + let original_ids: Vec = items.iter().map(|item| item.track.id).collect(); + let original_order_json = serde_json::to_string(&original_ids).unwrap_or_default(); + + // Parse play-next track IDs + let play_next_ids: Vec = state + .play_next_track_ids_json + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + + let current_idx = state.current_index.max(0) as usize; + + // Separate: current track, play-next tracks, regular tracks + let mut current_fp: Option = None; + let mut play_next_fps: Vec = Vec::new(); + let mut regular_fps: Vec = Vec::new(); + + for (i, item) in items.iter().enumerate() { + if i == current_idx { + current_fp = Some(item.track.filepath.clone()); + } else if play_next_ids.contains(&item.track.id) { + play_next_fps.push(item.track.filepath.clone()); + } else { + regular_fps.push(item.track.filepath.clone()); + } + } + + // Fisher-Yates shuffle regular tracks + regular_fps.shuffle(&mut rng()); + + // Rebuild: [current, play-next..., shuffled...] + tx.execute("DELETE FROM queue", [])?; + if let Some(fp) = ¤t_fp { + tx.execute("INSERT INTO queue (filepath) VALUES (?)", params![fp])?; + } + for fp in &play_next_fps { + tx.execute("INSERT INTO queue (filepath) VALUES (?)", params![fp])?; + } + for fp in ®ular_fps { + tx.execute("INSERT INTO queue (filepath) VALUES (?)", params![fp])?; + } + + // Update state + tx.execute( + "UPDATE queue_state SET current_index = 0, shuffle_enabled = 1, + original_order_json = ? WHERE id = 1", + params![original_order_json], + )?; + } else { + // Restore original order + let original_ids: Vec = state + .original_order_json + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + + if original_ids.is_empty() { + // No original order saved — just toggle the flag + tx.execute( + "UPDATE queue_state SET shuffle_enabled = 0, original_order_json = NULL WHERE id = 1", + [], + )?; + } else { + // Build filepath lookup from current queue + let fp_map: std::collections::HashMap = items + .iter() + .map(|item| (item.track.id, item.track.filepath.clone())) + .collect(); + + // Find current track ID before rebuilding + let current_track_id = if current_idx_valid(&state, &items) { + Some(items[state.current_index as usize].track.id) + } else { + None + }; + + // Rebuild queue in original order + tx.execute("DELETE FROM queue", [])?; + for id in &original_ids { + if let Some(fp) = fp_map.get(id) { + tx.execute("INSERT INTO queue (filepath) VALUES (?)", params![fp])?; + } + } + + // Find current track's new index in restored order + let new_index = current_track_id + .and_then(|tid| original_ids.iter().position(|id| *id == tid)) + .map(|i| i as i64) + .unwrap_or(0); + + tx.execute( + "UPDATE queue_state SET current_index = ?, shuffle_enabled = 0, + original_order_json = NULL WHERE id = 1", + params![new_index], + )?; + } + } + + tx.commit()?; + get_queue(conn) +} + +/// Reshuffle the queue for loop restart (loop=all wrapping back to start). +/// +/// Different from toggle_shuffle: the just-played track goes to the END +/// (not index 0) to avoid immediate repetition. Does not touch original_order_json. +pub(crate) fn reshuffle_for_loop_restart(conn: &Connection) -> DbResult> { + use rand::rng; + use rand::seq::SliceRandom; + + let state = get_queue_state(conn)?; + let items = get_queue(conn)?; + + if items.len() <= 1 { + return Ok(items); + } + + let current_idx = state.current_index.max(0) as usize; + let current_fp = items + .get(current_idx) + .map(|item| item.track.filepath.clone()); + + // Separate current track from the rest, shuffle the rest + let mut other_fps: Vec = items + .iter() + .enumerate() + .filter(|(i, _)| *i != current_idx) + .map(|(_, item)| item.track.filepath.clone()) + .collect(); + other_fps.shuffle(&mut rng()); + + // Rebuild: [shuffled..., just-played at END] + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM queue", [])?; + for fp in &other_fps { + tx.execute("INSERT INTO queue (filepath) VALUES (?)", params![fp])?; + } + if let Some(fp) = ¤t_fp { + tx.execute("INSERT INTO queue (filepath) VALUES (?)", params![fp])?; + } + + // Current index is 0 (first track in reshuffled order) + tx.execute("UPDATE queue_state SET current_index = 0 WHERE id = 1", [])?; + + tx.commit()?; + get_queue(conn) +} + +/// Check if current_index in state points to a valid queue item +fn current_idx_valid(state: &QueueState, items: &[QueueItem]) -> bool { + state.current_index >= 0 && (state.current_index as usize) < items.len() +} + +/// Result of a navigation operation (advance_to_next/previous) +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum NavigationAction { + /// Play the track at the given index + Play(usize), + /// Stop playback (end of queue, no loop) + Stop, + /// Restart current track from beginning (>3sec threshold) + SeekZero, +} + +/// Advance to the next track in the queue. +/// +/// Handles repeat-one two-phase logic, loop modes, history, and reshuffle on loop restart. +/// Returns the action to take and the updated queue items (may change if reshuffled). +pub(crate) fn advance_to_next(conn: &Connection) -> DbResult<(NavigationAction, Vec)> { + let state = get_queue_state(conn)?; + let items = get_queue(conn)?; + + if items.is_empty() { + return Ok((NavigationAction::Stop, items)); + } + + let current_idx = state.current_index.max(0) as usize; + + // Phase 2 of repeat-one: flag was set on previous call, clear and advance normally + if state.repeat_one_pending { + set_repeat_one_pending(conn, false)?; + // Fall through to normal advance logic + } + + // Phase 1 of repeat-one: set flag, change loop to "none", replay + if state.loop_mode == "one" { + set_repeat_one_pending(conn, true)?; + set_loop_mode(conn, "none")?; + return Ok((NavigationAction::Play(current_idx), items)); + } + + // Push current track to history + if current_idx < items.len() { + push_to_history(conn, &state, items[current_idx].track.id)?; + } + + let next_idx = current_idx + 1; + + if next_idx >= items.len() { + // End of queue + if state.loop_mode == "all" { + // Loop restart + let new_items = if state.shuffle_enabled { + reshuffle_for_loop_restart(conn)? + } else { + items + }; + // Reset to index 0 + set_current_index(conn, 0)?; + set_play_next_offset(conn, 0)?; + // Remove from play_next_track_ids if present + if !new_items.is_empty() { + remove_from_play_next_ids(conn, new_items[0].track.id)?; + } + return Ok((NavigationAction::Play(0), new_items)); + } else { + return Ok((NavigationAction::Stop, items)); + } + } + + // Normal advance + set_current_index(conn, next_idx as i64)?; + set_play_next_offset(conn, 0)?; + remove_from_play_next_ids(conn, items[next_idx].track.id)?; + + Ok((NavigationAction::Play(next_idx), items)) +} + +/// Advance to the previous track in the queue. +/// +/// If current_time_ms > 3000, returns SeekZero to restart current track. +/// Otherwise tries history, then falls back to decrementing index. +pub(crate) fn advance_to_previous( + conn: &Connection, + current_time_ms: u64, +) -> DbResult<(NavigationAction, Vec)> { + let state = get_queue_state(conn)?; + let items = get_queue(conn)?; + + if items.is_empty() { + return Ok((NavigationAction::Stop, items)); + } + + // >3 seconds into track: restart + if current_time_ms > 3000 { + return Ok((NavigationAction::SeekZero, items)); + } + + // Try history + let history: Vec = state + .play_history_json + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + + if !history.is_empty() { + // Pop from history and find the track in the current queue + let mut remaining = history.clone(); + while let Some(track_id) = remaining.pop() { + if let Some(idx) = items.iter().position(|item| item.track.id == track_id) { + // Save remaining history + let json = if remaining.is_empty() { + None + } else { + Some(serde_json::to_string(&remaining).unwrap_or_default()) + }; + set_play_history_json(conn, json)?; + set_current_index(conn, idx as i64)?; + return Ok((NavigationAction::Play(idx), items)); + } + // Track no longer in queue, try next history entry + } + // All history entries exhausted + set_play_history_json(conn, None)?; + } + + // Fallback: decrement index + let current_idx = state.current_index.max(0) as usize; + if current_idx > 0 { + let prev_idx = current_idx - 1; + set_current_index(conn, prev_idx as i64)?; + return Ok((NavigationAction::Play(prev_idx), items)); + } + + // At start of queue + if state.loop_mode == "all" && items.len() > 1 { + let last_idx = items.len() - 1; + set_current_index(conn, last_idx as i64)?; + return Ok((NavigationAction::Play(last_idx), items)); + } + + // Stay at beginning + Ok((NavigationAction::Play(0), items)) +} + +/// Skip next: override repeat-one by changing to loop=all, then advance. +pub(crate) fn skip_to_next(conn: &Connection) -> DbResult<(NavigationAction, Vec)> { + let state = get_queue_state(conn)?; + if state.loop_mode == "one" || state.repeat_one_pending { + set_loop_mode(conn, "all")?; + set_repeat_one_pending(conn, false)?; + } + advance_to_next(conn) +} + +/// Skip previous: override repeat-one by changing to loop=all, then go previous. +pub(crate) fn skip_to_previous( + conn: &Connection, + current_time_ms: u64, +) -> DbResult<(NavigationAction, Vec)> { + let state = get_queue_state(conn)?; + if state.loop_mode == "one" || state.repeat_one_pending { + set_loop_mode(conn, "all")?; + set_repeat_one_pending(conn, false)?; + } + advance_to_previous(conn, current_time_ms) +} + +/// Push a track ID to the play history (FIFO, capped at 100) +fn push_to_history(conn: &Connection, state: &QueueState, track_id: i64) -> DbResult<()> { + let mut history: Vec = state + .play_history_json + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + + history.push(track_id); + + // Cap at 100 + if history.len() > 100 { + history.drain(..history.len() - 100); + } + + let json = serde_json::to_string(&history).unwrap_or_default(); + set_play_history_json(conn, Some(json)) +} + +/// Remove a track ID from the play_next_track_ids set +fn remove_from_play_next_ids(conn: &Connection, track_id: i64) -> DbResult<()> { + let state = get_queue_state(conn)?; + let mut ids: Vec = state + .play_next_track_ids_json + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + + if ids.is_empty() { + return Ok(()); + } + + ids.retain(|id| *id != track_id); + let json = if ids.is_empty() { + None + } else { + Some(serde_json::to_string(&ids).unwrap_or_default()) + }; + set_play_next_track_ids_json(conn, json) +} + +/// Report from queue integrity check +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct IntegrityReport { + pub duplicate_track_ids: Vec, + pub index_was_out_of_bounds: bool, + pub orphaned_play_next_ids: Vec, + pub repaired: bool, +} + +/// Check queue integrity and repair issues. +/// +/// Detects: duplicate track IDs, out-of-bounds current_index, orphaned play_next_track_ids. +/// Auto-repairs by deduplicating and clamping index. +pub(crate) fn check_integrity(conn: &Connection) -> DbResult { + let items = get_queue(conn)?; + let state = get_queue_state(conn)?; + + let mut duplicates = Vec::new(); + let mut index_oob = false; + let mut orphaned = Vec::new(); + let mut needs_repair = false; + + // Check for duplicate track IDs + let mut seen = std::collections::HashSet::new(); + for item in &items { + if !seen.insert(item.track.id) { + duplicates.push(item.track.id); + } + } + + // Check current_index bounds + if !items.is_empty() && (state.current_index < 0 || state.current_index as usize >= items.len()) + { + index_oob = true; + } + if items.is_empty() && state.current_index != -1 { + index_oob = true; + } + + // Check play_next_track_ids for orphans + let play_next_ids: Vec = state + .play_next_track_ids_json + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + let queue_track_ids: std::collections::HashSet = + items.iter().map(|i| i.track.id).collect(); + for id in &play_next_ids { + if !queue_track_ids.contains(id) { + orphaned.push(*id); + } + } + + // Repair if needed + if !duplicates.is_empty() { + needs_repair = true; + // Deduplicate: keep first occurrence of each track ID + let mut dedup_seen = std::collections::HashSet::new(); + let keep_fps: Vec = items + .iter() + .filter(|item| dedup_seen.insert(item.track.id)) + .map(|item| item.track.filepath.clone()) + .collect(); + + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM queue", [])?; + for fp in &keep_fps { + tx.execute("INSERT INTO queue (filepath) VALUES (?)", params![fp])?; + } + tx.commit()?; + } + + if index_oob { + needs_repair = true; + let new_items = get_queue(conn)?; + let clamped = if new_items.is_empty() { + -1 + } else { + state.current_index.max(0).min(new_items.len() as i64 - 1) + }; + set_current_index(conn, clamped)?; + } + + if !orphaned.is_empty() { + needs_repair = true; + let cleaned: Vec = play_next_ids + .iter() + .filter(|id| queue_track_ids.contains(id)) + .copied() + .collect(); + let json = if cleaned.is_empty() { + None + } else { + Some(serde_json::to_string(&cleaned).unwrap_or_default()) + }; + set_play_next_track_ids_json(conn, json)?; + } + + Ok(IntegrityReport { + duplicate_track_ids: duplicates, + index_was_out_of_bounds: index_oob, + orphaned_play_next_ids: orphaned, + repaired: needs_repair, + }) +} + /// Result of a play-context operation: the installed queue and the track to play. #[cfg_attr(not(test), allow(dead_code))] pub(crate) struct PlayContextResult { @@ -487,6 +1140,10 @@ pub(crate) fn play_context( shuffle_enabled: shuffle, loop_mode: current_state.loop_mode, original_order_json: original_order_json.clone(), + play_next_offset: 0, + play_history_json: None, + play_next_track_ids_json: None, + repeat_one_pending: false, }; set_queue_state(&tx, &new_state)?; @@ -669,6 +1326,10 @@ mod tests { shuffle_enabled: true, loop_mode: "all".to_string(), original_order_json: Some("[1,2,3]".to_string()), + play_next_offset: 0, + play_history_json: None, + play_next_track_ids_json: None, + repeat_one_pending: false, }; set_queue_state(&conn, &state).unwrap(); @@ -1020,6 +1681,610 @@ mod tests { assert_eq!(result.items[1].track.id, track_ids[4]); assert_eq!(result.items[2].track.id, track_ids[5]); } + + #[test] + fn test_queue_state_new_fields_defaults() { + let conn = setup_test_db(); + + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.play_next_offset, 0); + assert!(state.play_history_json.is_none()); + assert!(state.play_next_track_ids_json.is_none()); + assert!(!state.repeat_one_pending); + } + + #[test] + fn test_set_play_next_offset() { + let conn = setup_test_db(); + + set_play_next_offset(&conn, 3).unwrap(); + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.play_next_offset, 3); + + set_play_next_offset(&conn, 0).unwrap(); + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.play_next_offset, 0); + } + + #[test] + fn test_set_play_history_json() { + let conn = setup_test_db(); + + set_play_history_json(&conn, Some("[1,2,3]".to_string())).unwrap(); + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.play_history_json, Some("[1,2,3]".to_string())); + + set_play_history_json(&conn, None).unwrap(); + let state = get_queue_state(&conn).unwrap(); + assert!(state.play_history_json.is_none()); + } + + #[test] + fn test_set_play_next_track_ids_json() { + let conn = setup_test_db(); + + set_play_next_track_ids_json(&conn, Some("[10,20]".to_string())).unwrap(); + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.play_next_track_ids_json, Some("[10,20]".to_string())); + + set_play_next_track_ids_json(&conn, None).unwrap(); + let state = get_queue_state(&conn).unwrap(); + assert!(state.play_next_track_ids_json.is_none()); + } + + #[test] + fn test_set_repeat_one_pending() { + let conn = setup_test_db(); + + set_repeat_one_pending(&conn, true).unwrap(); + let state = get_queue_state(&conn).unwrap(); + assert!(state.repeat_one_pending); + + set_repeat_one_pending(&conn, false).unwrap(); + let state = get_queue_state(&conn).unwrap(); + assert!(!state.repeat_one_pending); + } + + #[test] + fn test_set_queue_state_preserves_new_fields() { + let conn = setup_test_db(); + + let state = QueueState { + current_index: 2, + shuffle_enabled: true, + loop_mode: "one".to_string(), + original_order_json: None, + play_next_offset: 5, + play_history_json: Some("[7,8,9]".to_string()), + play_next_track_ids_json: Some("[11,12]".to_string()), + repeat_one_pending: true, + }; + set_queue_state(&conn, &state).unwrap(); + + let retrieved = get_queue_state(&conn).unwrap(); + assert_eq!(retrieved.current_index, 2); + assert!(retrieved.shuffle_enabled); + assert_eq!(retrieved.loop_mode, "one"); + assert_eq!(retrieved.play_next_offset, 5); + assert_eq!(retrieved.play_history_json, Some("[7,8,9]".to_string())); + assert_eq!( + retrieved.play_next_track_ids_json, + Some("[11,12]".to_string()) + ); + assert!(retrieved.repeat_one_pending); + } + + // ==================== Toggle Shuffle Tests ==================== + + #[test] + fn test_toggle_shuffle_pins_current_at_index_zero() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 5); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 2).unwrap(); + + let items = toggle_shuffle(&conn, true).unwrap(); + + // Current track (originally at index 2) should be at index 0 + assert_eq!(items[0].track.id, track_ids[2]); + assert_eq!(items.len(), 5); + + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.current_index, 0); + assert!(state.shuffle_enabled); + assert!(state.original_order_json.is_some()); + } + + #[test] + fn test_toggle_shuffle_pins_play_next_tracks() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 5); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 0).unwrap(); + + // Mark tracks 3 and 4 as play-next + let play_next_ids = serde_json::to_string(&vec![track_ids[2], track_ids[3]]).unwrap(); + set_play_next_track_ids_json(&conn, Some(play_next_ids)).unwrap(); + + let items = toggle_shuffle(&conn, true).unwrap(); + + // Current track at 0, play-next tracks at 1-2 + assert_eq!(items[0].track.id, track_ids[0]); + assert_eq!(items[1].track.id, track_ids[2]); + assert_eq!(items[2].track.id, track_ids[3]); + assert_eq!(items.len(), 5); + } + + #[test] + fn test_toggle_shuffle_unshuffle_restores_order() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 5); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 2).unwrap(); + + // Shuffle + toggle_shuffle(&conn, true).unwrap(); + + // Unshuffle + let items = toggle_shuffle(&conn, false).unwrap(); + + // Original order restored + for (i, item) in items.iter().enumerate() { + assert_eq!(item.track.id, track_ids[i]); + } + + let state = get_queue_state(&conn).unwrap(); + assert!(!state.shuffle_enabled); + assert!(state.original_order_json.is_none()); + // Current track should be found at its original position + assert_eq!(state.current_index, 2); + } + + #[test] + fn test_toggle_shuffle_preserves_all_tracks() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 10); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 0).unwrap(); + + let items = toggle_shuffle(&conn, true).unwrap(); + + // Same count + assert_eq!(items.len(), 10); + + // Same set of track IDs (permutation) + let mut shuffled_ids: Vec = items.iter().map(|i| i.track.id).collect(); + shuffled_ids.sort(); + let mut original_ids = track_ids.clone(); + original_ids.sort(); + assert_eq!(shuffled_ids, original_ids); + } + + #[test] + fn test_toggle_shuffle_empty_queue() { + let conn = setup_test_db(); + + let items = toggle_shuffle(&conn, true).unwrap(); + assert!(items.is_empty()); + + let state = get_queue_state(&conn).unwrap(); + assert!(state.shuffle_enabled); + } + + #[test] + fn test_reshuffle_for_loop_restart_puts_current_at_end() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 5); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 2).unwrap(); + + let items = reshuffle_for_loop_restart(&conn).unwrap(); + + // Just-played track (originally at index 2) should be at the END + assert_eq!(items.last().unwrap().track.id, track_ids[2]); + // Current index should be 0 (start of reshuffled queue) + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.current_index, 0); + // All tracks preserved + assert_eq!(items.len(), 5); + } + + #[test] + fn test_reshuffle_for_loop_restart_single_track() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 1); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 0).unwrap(); + + let items = reshuffle_for_loop_restart(&conn).unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].track.id, track_ids[0]); + } + + // ==================== Play-Next Tests ==================== + + #[test] + fn test_add_play_next_at_offset_zero() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 5); + add_to_queue(&conn, &track_ids[..3], None).unwrap(); + set_current_index(&conn, 0).unwrap(); + + // Add track 4 as play-next + let items = add_play_next(&conn, &[track_ids[3]]).unwrap(); + + // Should be: track1(current), track4(play-next), track2, track3 + assert_eq!(items[0].track.id, track_ids[0]); + assert_eq!(items[1].track.id, track_ids[3]); + assert_eq!(items[2].track.id, track_ids[1]); + assert_eq!(items[3].track.id, track_ids[2]); + + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.play_next_offset, 1); + } + + #[test] + fn test_add_play_next_stacked() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 6); + add_to_queue(&conn, &track_ids[..3], None).unwrap(); + set_current_index(&conn, 0).unwrap(); + + // First play-next: track4 + add_play_next(&conn, &[track_ids[3]]).unwrap(); + // Second play-next: track5 — should go AFTER track4 + let items = add_play_next(&conn, &[track_ids[4]]).unwrap(); + + // Should be: track1, track4, track5, track2, track3 + assert_eq!(items[0].track.id, track_ids[0]); + assert_eq!(items[1].track.id, track_ids[3]); + assert_eq!(items[2].track.id, track_ids[4]); + assert_eq!(items[3].track.id, track_ids[1]); + assert_eq!(items[4].track.id, track_ids[2]); + + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.play_next_offset, 2); + } + + #[test] + fn test_add_play_next_move_semantics() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 4); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 0).unwrap(); + + // Move track3 (at index 2) to play-next position + let items = add_play_next(&conn, &[track_ids[2]]).unwrap(); + + // Should be: track1(current), track3(moved), track2, track4 + assert_eq!(items.len(), 4); // No duplicates + assert_eq!(items[0].track.id, track_ids[0]); + assert_eq!(items[1].track.id, track_ids[2]); + assert_eq!(items[2].track.id, track_ids[1]); + assert_eq!(items[3].track.id, track_ids[3]); + } + + #[test] + fn test_add_play_next_current_track_not_moved() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 0).unwrap(); + + // Try to play-next the currently playing track + let items = add_play_next(&conn, &[track_ids[0]]).unwrap(); + + // Current track should stay at index 0, and be duplicated as play-next + // (this matches frontend behavior — the track stays and also appears as play-next) + assert_eq!(items[0].track.id, track_ids[0]); + assert_eq!(items.len(), 4); // Original 3 + 1 inserted + } + + #[test] + fn test_add_play_next_updates_track_ids_json() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 4); + add_to_queue(&conn, &track_ids[..2], None).unwrap(); + set_current_index(&conn, 0).unwrap(); + + add_play_next(&conn, &[track_ids[2]]).unwrap(); + add_play_next(&conn, &[track_ids[3]]).unwrap(); + + let state = get_queue_state(&conn).unwrap(); + let ids: Vec = + serde_json::from_str(state.play_next_track_ids_json.as_deref().unwrap()).unwrap(); + assert!(ids.contains(&track_ids[2])); + assert!(ids.contains(&track_ids[3])); + } + + // ==================== Navigation Tests ==================== + + #[test] + fn test_advance_to_next_normal() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 0).unwrap(); + + let (action, _items) = advance_to_next(&conn).unwrap(); + assert_eq!(action, NavigationAction::Play(1)); + + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.current_index, 1); + } + + #[test] + fn test_advance_to_next_end_no_loop() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 2).unwrap(); + set_loop_mode(&conn, "none").unwrap(); + + let (action, _) = advance_to_next(&conn).unwrap(); + assert_eq!(action, NavigationAction::Stop); + } + + #[test] + fn test_advance_to_next_end_loop_all() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 2).unwrap(); + set_loop_mode(&conn, "all").unwrap(); + + let (action, _) = advance_to_next(&conn).unwrap(); + assert_eq!(action, NavigationAction::Play(0)); + + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.current_index, 0); + } + + #[test] + fn test_advance_to_next_repeat_one_two_phase() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 1).unwrap(); + set_loop_mode(&conn, "one").unwrap(); + + // Phase 1: sets repeat_one_pending, changes loop to none, replays current + let (action, _) = advance_to_next(&conn).unwrap(); + assert_eq!(action, NavigationAction::Play(1)); + let state = get_queue_state(&conn).unwrap(); + assert!(state.repeat_one_pending); + assert_eq!(state.loop_mode, "none"); + + // Phase 2: clears pending, advances normally (no second replay) + let (action, _) = advance_to_next(&conn).unwrap(); + assert_eq!(action, NavigationAction::Play(2)); + let state = get_queue_state(&conn).unwrap(); + assert!(!state.repeat_one_pending); + } + + #[test] + fn test_advance_to_next_pushes_history() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 0).unwrap(); + + advance_to_next(&conn).unwrap(); + + let state = get_queue_state(&conn).unwrap(); + let history: Vec = + serde_json::from_str(state.play_history_json.as_deref().unwrap()).unwrap(); + assert_eq!(history, vec![track_ids[0]]); + } + + #[test] + fn test_advance_to_next_resets_play_next_offset() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 0).unwrap(); + set_play_next_offset(&conn, 2).unwrap(); + + advance_to_next(&conn).unwrap(); + + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.play_next_offset, 0); + } + + #[test] + fn test_advance_to_previous_seek_zero() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 1).unwrap(); + + let (action, _) = advance_to_previous(&conn, 5000).unwrap(); + assert_eq!(action, NavigationAction::SeekZero); + } + + #[test] + fn test_advance_to_previous_uses_history() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 5); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 3).unwrap(); + + // Set history: track at index 0 was played before + let history = serde_json::to_string(&vec![track_ids[0]]).unwrap(); + set_play_history_json(&conn, Some(history)).unwrap(); + + let (action, _) = advance_to_previous(&conn, 0).unwrap(); + assert_eq!(action, NavigationAction::Play(0)); + + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.current_index, 0); + // History should be empty after popping + assert!(state.play_history_json.is_none()); + } + + #[test] + fn test_advance_to_previous_fallback_decrement() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 2).unwrap(); + + let (action, _) = advance_to_previous(&conn, 0).unwrap(); + assert_eq!(action, NavigationAction::Play(1)); + + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.current_index, 1); + } + + #[test] + fn test_advance_to_previous_loop_all_wrap() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 0).unwrap(); + set_loop_mode(&conn, "all").unwrap(); + + let (action, _) = advance_to_previous(&conn, 0).unwrap(); + assert_eq!(action, NavigationAction::Play(2)); + + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.current_index, 2); + } + + #[test] + fn test_skip_next_overrides_repeat_one() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 0).unwrap(); + set_loop_mode(&conn, "one").unwrap(); + + // skip_next should change loop to "all" and advance (not replay) + let (action, _) = skip_to_next(&conn).unwrap(); + assert_eq!(action, NavigationAction::Play(1)); + + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.loop_mode, "all"); + assert!(!state.repeat_one_pending); + } + + #[test] + fn test_skip_previous_overrides_repeat_one() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 1).unwrap(); + set_loop_mode(&conn, "one").unwrap(); + + let (action, _) = skip_to_previous(&conn, 0).unwrap(); + assert_eq!(action, NavigationAction::Play(0)); + + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.loop_mode, "all"); + } + + #[test] + fn test_advance_to_next_end_loop_all_shuffle_reshuffles() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 5); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 4).unwrap(); // Last track + set_loop_mode(&conn, "all").unwrap(); + set_shuffle_enabled(&conn, true).unwrap(); + + let (action, items) = advance_to_next(&conn).unwrap(); + assert_eq!(action, NavigationAction::Play(0)); + + // All 5 tracks preserved + assert_eq!(items.len(), 5); + // Just-played track (track_ids[4]) should be at the END + assert_eq!(items.last().unwrap().track.id, track_ids[4]); + } + + #[test] + fn test_advance_to_next_empty_queue() { + let conn = setup_test_db(); + + let (action, _) = advance_to_next(&conn).unwrap(); + assert_eq!(action, NavigationAction::Stop); + } + + // ==================== Integrity Check Tests ==================== + + #[test] + fn test_check_integrity_clean_queue() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 1).unwrap(); + + let report = check_integrity(&conn).unwrap(); + assert!(report.duplicate_track_ids.is_empty()); + assert!(!report.index_was_out_of_bounds); + assert!(report.orphaned_play_next_ids.is_empty()); + assert!(!report.repaired); + } + + #[test] + fn test_check_integrity_out_of_bounds_index() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 10).unwrap(); + + let report = check_integrity(&conn).unwrap(); + assert!(report.index_was_out_of_bounds); + assert!(report.repaired); + + let state = get_queue_state(&conn).unwrap(); + assert_eq!(state.current_index, 2); // Clamped to last valid index + } + + #[test] + fn test_check_integrity_orphaned_play_next_ids() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 3); + add_to_queue(&conn, &track_ids, None).unwrap(); + set_current_index(&conn, 0).unwrap(); + + // Set play-next IDs including one that doesn't exist in queue + let ids = serde_json::to_string(&vec![track_ids[1], 9999]).unwrap(); + set_play_next_track_ids_json(&conn, Some(ids)).unwrap(); + + let report = check_integrity(&conn).unwrap(); + assert_eq!(report.orphaned_play_next_ids, vec![9999]); + assert!(report.repaired); + + // Orphan should be removed + let state = get_queue_state(&conn).unwrap(); + let cleaned: Vec = + serde_json::from_str(state.play_next_track_ids_json.as_deref().unwrap()).unwrap(); + assert_eq!(cleaned, vec![track_ids[1]]); + } + + #[test] + fn test_check_integrity_duplicate_tracks() { + let conn = setup_test_db(); + let track_ids = add_test_tracks(&conn, 2); + // Manually insert duplicate + add_to_queue(&conn, &track_ids, None).unwrap(); + conn.execute( + "INSERT INTO queue (filepath) VALUES (?)", + params![format!("/music/track1.mp3")], + ) + .unwrap(); + set_current_index(&conn, 0).unwrap(); + + let report = check_integrity(&conn).unwrap(); + assert!(!report.duplicate_track_ids.is_empty()); + assert!(report.repaired); + + // After repair, no duplicates + let items = get_queue(&conn).unwrap(); + let ids: Vec = items.iter().map(|i| i.track.id).collect(); + let unique: std::collections::HashSet = ids.iter().copied().collect(); + assert_eq!(ids.len(), unique.len()); + } } #[cfg(test)] diff --git a/crates/mt-tauri/src/db/schema.rs b/crates/mt-tauri/src/db/schema.rs index 9f4bc4b5..c256eb7b 100644 --- a/crates/mt-tauri/src/db/schema.rs +++ b/crates/mt-tauri/src/db/schema.rs @@ -460,6 +460,41 @@ pub(crate) fn run_migrations(conn: &Connection) -> DbResult<()> { info!("play_history track_id index created"); } + // Migration: Add queue state machine columns for backend-owned queue logic + let qs_columns = get_table_columns(conn, "queue_state")?; + if !qs_columns.contains(&"play_next_offset".to_string()) { + info!("Adding play_next_offset column to queue_state table"); + conn.execute( + "ALTER TABLE queue_state ADD COLUMN play_next_offset INTEGER DEFAULT 0", + [], + )?; + info!("play_next_offset column added"); + } + if !qs_columns.contains(&"play_history_json".to_string()) { + info!("Adding play_history_json column to queue_state table"); + conn.execute( + "ALTER TABLE queue_state ADD COLUMN play_history_json TEXT", + [], + )?; + info!("play_history_json column added"); + } + if !qs_columns.contains(&"play_next_track_ids_json".to_string()) { + info!("Adding play_next_track_ids_json column to queue_state table"); + conn.execute( + "ALTER TABLE queue_state ADD COLUMN play_next_track_ids_json TEXT", + [], + )?; + info!("play_next_track_ids_json column added"); + } + if !qs_columns.contains(&"repeat_one_pending".to_string()) { + info!("Adding repeat_one_pending column to queue_state table"); + conn.execute( + "ALTER TABLE queue_state ADD COLUMN repeat_one_pending INTEGER DEFAULT 0", + [], + )?; + info!("repeat_one_pending column added"); + } + // Migration: Add file_ctime_ns column for creation time (birthtime) let library_columns = get_table_columns(conn, "library")?; if !library_columns.contains(&"file_ctime_ns".to_string()) { diff --git a/crates/mt-tauri/src/lib.rs b/crates/mt-tauri/src/lib.rs index e2255dbc..6ab910a2 100644 --- a/crates/mt-tauri/src/lib.rs +++ b/crates/mt-tauri/src/lib.rs @@ -32,11 +32,12 @@ use commands::{ network_cache_purge, network_cache_status, playlist_add_tracks, playlist_create, playlist_delete, playlist_generate_name, playlist_get, playlist_list, playlist_remove_track, playlist_reorder_tracks, playlist_update, playlists_reorder, queue_add, queue_add_files, - queue_clear, queue_get, queue_get_playback_state, queue_play_context, queue_remove, + queue_add_play_next, queue_check_integrity, queue_clear, queue_get, queue_get_playback_state, + queue_play_context, queue_play_next_track, queue_play_previous_track, queue_remove, queue_reorder, queue_set_current_index, queue_set_loop, queue_set_shuffle, queue_shuffle, - settings_get, settings_get_all, settings_reset, settings_set, settings_update, - stats_generate_chart_grid, stats_get_genres, stats_get_overview, stats_get_plays_over_time, - stats_get_top_artists, + queue_skip_next, queue_skip_previous, settings_get, settings_get_all, settings_reset, + settings_set, settings_update, stats_generate_chart_grid, stats_get_genres, stats_get_overview, + stats_get_plays_over_time, stats_get_top_artists, }; use dialog::{open_add_music_dialog, open_file_dialog, open_folder_dialog}; use library::commands::{ @@ -511,6 +512,12 @@ pub fn run() { queue_set_current_index, queue_set_shuffle, queue_set_loop, + queue_add_play_next, + queue_play_next_track, + queue_play_previous_track, + queue_skip_next, + queue_skip_previous, + queue_check_integrity, queue_play_context, playlist_list, playlist_create, From 5ade19c8d2562fcf2f0b0e463d6ca4e5b114678c Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:21:56 -0500 Subject: [PATCH 2/4] fix(queue): resolve deno lint errors in queue store Remove unnecessary async from _applyNavigationResult (no await inside). Prefix unused fromNavigation parameter with underscore. Co-Authored-By: Claude Opus 4.6 --- app/frontend/js/stores/queue.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/frontend/js/stores/queue.js b/app/frontend/js/stores/queue.js index 8c9a356e..1b295ff3 100644 --- a/app/frontend/js/stores/queue.js +++ b/app/frontend/js/stores/queue.js @@ -155,7 +155,7 @@ export function createQueueStore(Alpine) { * @param {Object} result - QueueNavigationResult from backend * @returns {string} The action taken: 'play', 'stop', or 'seek_zero' */ - async _applyNavigationResult(result) { + _applyNavigationResult(result) { if (!result) return 'stop'; this._applySnapshot(result.snapshot); @@ -393,7 +393,7 @@ export function createQueueStore(Alpine) { * @param {number} index - Index to play * @param {boolean} fromNavigation - If true, this is from backend navigation (history already handled) */ - async playIndex(index, fromNavigation = false) { + async playIndex(index, _fromNavigation = false) { if (index < 0 || index >= this.items.length) return; this.currentIndex = index; @@ -420,7 +420,7 @@ export function createQueueStore(Alpine) { this._updating = true; try { const result = await queueApi.playNextTrack(); - const action = await this._applyNavigationResult(result); + const action = this._applyNavigationResult(result); if (action === 'stop') { Alpine.store('player').isPlaying = false; @@ -446,7 +446,7 @@ export function createQueueStore(Alpine) { const player = Alpine.store('player'); const currentTimeMs = player.currentTime || 0; const result = await queueApi.playPreviousTrack(currentTimeMs); - const action = await this._applyNavigationResult(result); + const action = this._applyNavigationResult(result); if (action === 'seek_zero') { await player.seek(0); @@ -468,7 +468,7 @@ export function createQueueStore(Alpine) { this._updating = true; try { const result = await queueApi.skipNext(); - const action = await this._applyNavigationResult(result); + const action = this._applyNavigationResult(result); if (action === 'stop') { Alpine.store('player').isPlaying = false; @@ -492,7 +492,7 @@ export function createQueueStore(Alpine) { const player = Alpine.store('player'); const currentTimeMs = player.currentTime || 0; const result = await queueApi.skipPrevious(currentTimeMs); - const action = await this._applyNavigationResult(result); + const action = this._applyNavigationResult(result); if (action === 'seek_zero') { await player.seek(0); From 060ca7608eb04a06ab264f7006c79fb65c88b0f7 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:25:59 -0500 Subject: [PATCH 3/4] fix(lint): collapse nested if in watcher scan completion Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/mt-tauri/src/watcher.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/mt-tauri/src/watcher.rs b/crates/mt-tauri/src/watcher.rs index 534f137a..d1ea8857 100644 --- a/crates/mt-tauri/src/watcher.rs +++ b/crates/mt-tauri/src/watcher.rs @@ -686,17 +686,17 @@ impl WatcherManager { ); // Emit a reconcile event with authoritative stats covering all changes. - if added > 0 || updated > 0 || deleted > 0 { - if let Ok(conn) = db.conn() { - let stats = crate::db::library::get_library_stats(&conn).unwrap_or_default(); - let rev = revision::get_revision(&conn).unwrap_or(0); - let _ = app.emit_library_reconcile(LibraryReconcileEvent::scan_complete( - vec![], - stats.total_tracks, - stats.total_duration as f64, - rev, - )); - } + if (added > 0 || updated > 0 || deleted > 0) + && let Ok(conn) = db.conn() + { + let stats = crate::db::library::get_library_stats(&conn).unwrap_or_default(); + let rev = revision::get_revision(&conn).unwrap_or(0); + let _ = app.emit_library_reconcile(LibraryReconcileEvent::scan_complete( + vec![], + stats.total_tracks, + stats.total_duration as f64, + rev, + )); } let _ = app.emit( From d86b34c6a657ceb5c2d7ebcc629bc34fe34cfd21 Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:34:46 -0500 Subject: [PATCH 4/4] fix(queue): optimistic shuffle toggle and aria-pressed for accessibility toggleShuffle now sets this.shuffle before the backend call (like cycleLoop does for loop mode), reverting on failure. Add aria-pressed to the shuffle button so the E2E accessibility test detects state change. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/frontend/js/stores/queue.js | 5 ++++- app/frontend/views/footer.html | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/frontend/js/stores/queue.js b/app/frontend/js/stores/queue.js index 1b295ff3..c7dcc6af 100644 --- a/app/frontend/js/stores/queue.js +++ b/app/frontend/js/stores/queue.js @@ -512,11 +512,14 @@ export function createQueueStore(Alpine) { */ async toggleShuffle() { this._updating = true; + const prev = this.shuffle; + this.shuffle = !prev; try { - const snapshot = await queueApi.setShuffle(!this.shuffle); + const snapshot = await queueApi.setShuffle(this.shuffle); this._applySnapshot(snapshot); } catch (error) { console.error('[queue] toggleShuffle failed:', error); + this.shuffle = prev; } finally { setTimeout(() => { this._updating = false; diff --git a/app/frontend/views/footer.html b/app/frontend/views/footer.html index e9f6fa77..e299a5c4 100644 --- a/app/frontend/views/footer.html +++ b/app/frontend/views/footer.html @@ -151,6 +151,7 @@