[READ-ONLY] Mirror of https://github.com/probablykasper/ferrum. Music library app for Mac, Linux and Windows ferrum.kasper.space
electron linux macos music music-library music-player napi windows
0

Configure Feed

Select the types of activity you want to include in your feed.

Non-intrusive media key permission request

Kasper (Sep 14, 2025, 5:10 AM +0200) 143cf351 f81e6201

+123 -65
+3
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## Next 4 + - Make macOS media key permission request non-intrusive 5 + 3 6 ## 0.19.9 - 2025 Jul 4 4 7 - Fix column size not updating when resizing window 5 8 - Fix column sorting not working
+67 -2
src/App.svelte
··· 1 1 <script lang="ts"> 2 2 import { onDestroy, onMount } from 'svelte' 3 - import { fade } from 'svelte/transition' 3 + import { fade, fly } from 'svelte/transition' 4 4 import TrackList from './components/TrackList.svelte' 5 5 import Player from './components/Player.svelte' 6 6 import Sidebar from './components/Sidebar.svelte' ··· 23 23 import './lib/router' 24 24 import CheckForUpdates from './components/CheckForUpdates.svelte' 25 25 import Settings from './components/Settings.svelte' 26 + import Button from './components/Button.svelte' 26 27 27 28 ipc_renderer.invoke('app_loaded').catch(() => { 28 29 ipc_renderer.invoke('showMessageBox', false, { ··· 31 32 detail: 'Graceful shutdown will not be possible.', 32 33 }) 33 34 }) 35 + 36 + let media_keys_result: Awaited<ReturnType<typeof init_media_keys>> | null = null 37 + async function init_media_keys(prompt: boolean) { 38 + return await ipc_renderer.invoke('init_media_keys', prompt) 39 + } 40 + init_media_keys(false).then((result) => (media_keys_result = result)) 34 41 35 42 async function open_import_dialog() { 36 43 if ($modal_count !== 0) { ··· 179 186 }) 180 187 </script> 181 188 182 - <svelte:window on:keydown={keydown} /> 189 + <svelte:window 190 + on:keydown={keydown} 191 + on:focus={async () => { 192 + if (media_keys_result?.needs_accessibility_permission) { 193 + media_keys_result = null 194 + media_keys_result = await ipc_renderer.invoke('init_media_keys', false) 195 + } 196 + }} 197 + /> 183 198 <svelte:head> 184 199 <title>Ferrum</title> 185 200 </svelte:head> ··· 204 219 <div class="flex size-full min-w-0 flex-col"> 205 220 <Route route="/playlist/:playlist_id" component={TrackList} /> 206 221 <Route route="/artists" component={ArtistList} /> 222 + {#if media_keys_result} 223 + <div class="shrink-0 overflow-hidden"> 224 + <div 225 + class="flex items-center border-t border-r border-l border-red-500/15 bg-red-500/10 px-2 py-1 text-[15px] text-red-500 select-text" 226 + in:fly|global={{ y: '100%', duration: 200 }} 227 + > 228 + {#if media_keys_result.error} 229 + {media_keys_result.error} 230 + <Button 231 + type="button" 232 + secondary 233 + thin 234 + on:click={async () => { 235 + media_keys_result = null 236 + media_keys_result = await ipc_renderer.invoke('init_media_keys', false) 237 + }}>Retry</Button 238 + > 239 + <Button 240 + type="button" 241 + secondary 242 + thin 243 + on:click={() => { 244 + media_keys_result = null 245 + }}>Ignore</Button 246 + > 247 + {:else if media_keys_result.needs_accessibility_permission} 248 + Do you want to Ferrum to control the media keys? You may need to delete and re-add the 249 + app from the accessibility list 250 + <Button 251 + class="text-nowrap" 252 + type="button" 253 + secondary 254 + thin 255 + on:click={async () => { 256 + media_keys_result = null 257 + media_keys_result = await ipc_renderer.invoke('init_media_keys', true) 258 + }}>Give permission</Button 259 + > 260 + <Button 261 + type="button" 262 + secondary 263 + thin 264 + on:click={() => { 265 + media_keys_result = null 266 + }}>Ignore</Button 267 + > 268 + {/if} 269 + </div> 270 + </div> 271 + {/if} 207 272 </div> 208 273 {#if $queue_visible} 209 274 <Queue />
+15 -1
src/components/Button.svelte
··· 5 5 interface $$Props extends HTMLBaseAttributes { 6 6 secondary?: boolean 7 7 danger?: boolean 8 + thin?: boolean 8 9 type?: 'button' | 'submit' | 'reset' 9 10 } 10 11 11 12 export let secondary = false 12 13 export let danger = false 14 + export let thin = false 13 15 export let type: 'button' | 'submit' | 'reset' = 'button' 14 16 let normal = !danger && !secondary 15 17 $: normal = !danger && !secondary 16 18 </script> 17 19 18 - <button on:click on:mousedown class:normal class:secondary class:danger {type} {...$$restProps}> 20 + <button 21 + on:click 22 + on:mousedown 23 + class:normal 24 + class:secondary 25 + class:danger 26 + class:thin 27 + {type} 28 + {...$$restProps} 29 + > 19 30 <slot /> 20 31 </button> 21 32 ··· 44 55 right: 0px 45 56 border-radius: 7px 46 57 transition: all 120ms var(--cubic-out) 58 + button.thin 59 + padding: 4px 10px 60 + font-size: 12px 47 61 button.normal 48 62 &::before 49 63 background-color: hsl(220, 100%, 46%)
+5
src/electron/ipc.ts
··· 3 3 import path from 'path' 4 4 import is from './is' 5 5 import { check_for_updates } from './update' 6 + import { init_media_keys } from './shortcuts' 6 7 7 8 ipc_main.handle('check_for_updates', (_e) => { 8 9 return check_for_updates() ?? null ··· 42 43 } else { 43 44 Menu.getApplicationMenu()?.getMenuItemById('Volume Down')?.click() 44 45 } 46 + }) 47 + 48 + ipc_main.handle('init_media_keys', async (_e, prompt) => { 49 + return await init_media_keys(prompt) 45 50 }) 46 51 47 52 ipc_main.handle('show_tracks_menu', (e, options) => {
+6 -5
src/electron/main.ts
··· 5 5 if (is.dev) app.setName('Ferrum Dev') 6 6 7 7 import { init_menu_bar } from './menubar' 8 - import { init_media_keys } from './shortcuts' 9 8 import('./ipc') 10 9 import path from 'path' 11 10 import url from 'url' ··· 60 59 }, 61 60 ]) 62 61 62 + export const browser_windows = { 63 + main_window: null as BrowserWindow | null, 64 + } 65 + 63 66 app.whenReady().then(async () => { 64 67 let main_window: BrowserWindow | null = new BrowserWindow({ 65 68 width: 1305, ··· 75 78 backgroundColor: '#0D1115', 76 79 show: false, 77 80 }) 78 - 79 - if (!is.dev) { 80 - await init_media_keys(main_window) 81 - } 81 + browser_windows.main_window = main_window 82 82 83 83 protocol.registerFileProtocol('track', (request, callback) => { 84 84 const url = decodeURI(request.url) ··· 165 165 }) 166 166 main_window.on('closed', () => { 167 167 main_window = null 168 + browser_windows.main_window = main_window 168 169 }) 169 170 ipc_main.handle('app_loaded', () => { 170 171 app_loaded = true
+20 -57
src/electron/shortcuts.ts
··· 1 - import { globalShortcut, dialog, systemPreferences, BrowserWindow } from 'electron' 1 + import { globalShortcut, systemPreferences } from 'electron' 2 2 import is from './is' 3 - import type { WebContents } from './typed_ipc' 3 + import { browser_windows } from './main' 4 4 5 - function try_registering(main_window: BrowserWindow) { 6 - const web_contents = main_window.webContents as WebContents 5 + let was_success = false 6 + function try_registering() { 7 + if (was_success) { 8 + return true 9 + } 10 + const web_contents = browser_windows.main_window?.webContents 7 11 const success1 = globalShortcut.register('MediaPlayPause', () => { 8 - if (main_window !== null) web_contents.send('playPause') 12 + web_contents?.send('playPause') 9 13 }) 10 14 if (!success1) return false 11 15 globalShortcut.register('MediaNextTrack', () => { 12 - if (main_window !== null) web_contents.send('Next') 16 + web_contents?.send('Next') 13 17 }) 14 18 globalShortcut.register('MediaPreviousTrack', () => { 15 - if (main_window !== null) web_contents.send('Previous') 19 + web_contents?.send('Previous') 16 20 }) 17 21 globalShortcut.register('MediaStop', () => { 18 - if (main_window !== null) web_contents.send('Stop') 22 + web_contents?.send('Stop') 19 23 }) 24 + was_success = true 20 25 return true 21 26 } 22 27 23 - async function request_loop(main_window: BrowserWindow) { 24 - let first_request = true 25 - for (;;) { 26 - let msg = 'No accessibility permissions detected.' 27 - if (!first_request) { 28 - msg = 'Click Done when you have granted Ferrum accessibility permissions.' 29 - } else { 30 - first_request = true 31 - } 32 - const result = await dialog.showMessageBox(main_window, { 33 - type: 'info', 34 - message: `${msg} To grant them, open System Preferences, click Security & Privacy, click Privacy, click Accessibility, then select Ferrum's checkbox`, 35 - buttons: ['Done', 'Cancel'], 36 - defaultId: 0, 37 - }) 38 - if (result.response === 1 || systemPreferences.isTrustedAccessibilityClient(true)) { 39 - return 40 - } 41 - } 42 - } 43 - 44 - async function get_trusted_accesibility_macos(main_window: BrowserWindow) { 45 - const result = await dialog.showMessageBox(main_window, { 46 - type: 'info', 47 - message: 'Ferrum needs accessibility permissions to exclusively take over the media keys', 48 - buttons: ['Continue', 'Ignore'], 49 - defaultId: 0, 50 - }) 51 - if (result.response === 0) { 52 - setTimeout(() => { 53 - systemPreferences.isTrustedAccessibilityClient(true) // prompt 54 - }, 500) 55 - await request_loop(main_window) 56 - try_registering(main_window) 57 - } 58 - } 59 - 60 - export async function init_media_keys(main_window: BrowserWindow) { 61 - const success = try_registering(main_window) 28 + export async function init_media_keys(prompt: boolean) { 29 + const success = try_registering() 62 30 if (!success) { 63 31 if (is.mac) { 64 - if (systemPreferences.isTrustedAccessibilityClient(false)) { 65 - await dialog.showMessageBox(main_window, { 66 - type: 'info', 67 - message: 'Could not register media key shortcuts', 68 - }) 32 + if (systemPreferences.isTrustedAccessibilityClient(prompt)) { 33 + return { error: 'Could not register media key shortcuts' } 69 34 } else { 70 - await get_trusted_accesibility_macos(main_window) 35 + return { needs_accessibility_permission: true } 71 36 } 72 37 } else { 73 - await dialog.showMessageBox(main_window, { 74 - type: 'info', 75 - message: 'Could not register media key shortcuts', 76 - }) 38 + return { error: 'Could not register media key shortcuts' } 77 39 } 78 40 } 41 + return null 79 42 }
+7
src/electron/typed_ipc.ts
··· 162 162 showTracklistMenu: (options: { id: string; isFolder: boolean; isRoot: boolean }) => void 163 163 show_columns_menu: (options: { menu: MenuItemConstructorOptions[] }) => void 164 164 volume_change: (up: boolean) => void 165 + init_media_keys: ( 166 + prompt: boolean, 167 + ) => Promise< 168 + | { needs_accessibility_permission: undefined; error: string } 169 + | { needs_accessibility_permission: boolean; error: undefined } 170 + | null 171 + > 165 172 166 173 'update:Shuffle': (checked: boolean) => void 167 174 'update:Repeat': (checked: boolean) => void