From 63134df7d6237e8355694d007dc02bb495b77af2 Mon Sep 17 00:00:00 2001 From: daylily Date: Mon, 12 Jan 2026 18:23:13 -0600 Subject: [PATCH] Implement MIDI permission detection and user prompting --- webapp/src/App.svelte | 9 +- webapp/src/lib/contexts/device.svelte.ts | 93 ++++++++++--------- webapp/src/lib/contexts/midi.svelte.ts | 72 ++++++++++++++ webapp/src/lib/layouts/Main.svelte | 15 ++- .../dialog/PermissionDeniedDialog.svelte | 28 ++++++ .../dialog/PermissionRequestDialog.svelte | 32 +++++++ .../UnsupportedDialog.svelte} | 20 ++-- webapp/src/lib/utils.svelte.ts | 11 +++ 8 files changed, 216 insertions(+), 64 deletions(-) create mode 100644 webapp/src/lib/contexts/midi.svelte.ts create mode 100644 webapp/src/lib/layouts/dialog/PermissionDeniedDialog.svelte create mode 100644 webapp/src/lib/layouts/dialog/PermissionRequestDialog.svelte rename webapp/src/lib/layouts/{Unsupported.svelte => dialog/UnsupportedDialog.svelte} (63%) create mode 100644 webapp/src/lib/utils.svelte.ts diff --git a/webapp/src/App.svelte b/webapp/src/App.svelte index 84b609d..26522d7 100644 --- a/webapp/src/App.svelte +++ b/webapp/src/App.svelte @@ -4,19 +4,12 @@ import { Toaster } from 'svelte-sonner' import Footer from '$lib/layouts/Footer.svelte' import Main from '$lib/layouts/Main.svelte' -import Unsupported from '$lib/layouts/Unsupported.svelte' - -const unsupported = navigator.requestMIDIAccess === undefined
- {#if unsupported} - - {:else} -
- {/if} +
diff --git a/webapp/src/lib/contexts/device.svelte.ts b/webapp/src/lib/contexts/device.svelte.ts index 2eaa690..bba9540 100644 --- a/webapp/src/lib/contexts/device.svelte.ts +++ b/webapp/src/lib/contexts/device.svelte.ts @@ -1,6 +1,8 @@ import { Device } from '$lib/image/device' -import { getContext, setContext, untrack } from 'svelte' +import { getContext, setContext } from 'svelte' import { toast } from 'svelte-sonner' +import type { MidiContext } from './midi.svelte' +import { effect } from '$lib/utils.svelte' function isInkclip(port: MIDIPort): boolean { return !!( @@ -10,54 +12,59 @@ function isInkclip(port: MIDIPort): boolean { } export class DeviceContext { + private midi: MidiContext private input: MIDIInput | null = $state(null) private output: MIDIOutput | null = $state(null) device: Device | null = $state(null) - constructor() { - $effect(() => { - if (untrack(() => this.device == null)) { - if (this.input == null || this.output == null) return - this.device = new Device(this.input, this.output) - toast.info('Device connected') - } else { - if (this.input != null && this.output != null) return - this.device = null - toast.info('Device disconnected') - } - }) + constructor(midi: MidiContext) { + this.midi = midi + effect( + () => [this.midi.midi], + () => this.initialize(), + ) + effect( + () => [this.input, this.output], + () => this.update(), + ) } - async initialize() { - try { - const midi = await navigator.requestMIDIAccess({ sysex: true }) - const inputs = Array.from(midi.inputs.values()) - const outputs = Array.from(midi.outputs.values()) - console.log( - 'MIDI inputs:', - Array.from(midi.inputs.values()), - 'outputs:', - Array.from(midi.outputs.values()), - ) - - this.input = inputs.find(isInkclip) ?? null - this.output = outputs.find(isInkclip) ?? null - - midi.addEventListener('statechange', e => { - console.log(`MIDI port statechange:`, e.port) - if (!e.port || !isInkclip(e.port)) return - if (e.port?.state === 'connected') { - if (e.port.type === 'input' && !this.input) this.input = e.port as MIDIInput - else if (e.port.type === 'output' && !this.output) this.output = e.port as MIDIOutput - } else { - if (e.port.type === 'input' && e.port === this.input) this.input = null - else if (e.port.type === 'output' && e.port === this.output) this.output = null - } - }) - } catch (e) { - toast.error(`Failed to acquire MIDI access: ${e}`) + private update() { + if (this.device == null) { + if (this.input == null || this.output == null) return + this.device = new Device(this.input, this.output) + toast.info('Device connected') + } else { + if (this.input != null && this.output != null) return + this.device = null + toast.info('Device disconnected') } } + + private async initialize() { + if (!this.midi.midi) return + const midi = this.midi.midi + + const inputs = Array.from(midi.inputs.values()) + const outputs = Array.from(midi.outputs.values()) + console.log('MIDI inputs:', Array.from(midi.inputs.values())) + console.log('MIDI outputs:', Array.from(midi.outputs.values())) + + this.input = inputs.find(isInkclip) ?? null + this.output = outputs.find(isInkclip) ?? null + + midi.addEventListener('statechange', e => { + console.log(`MIDI port statechange:`, e.port) + if (!e.port || !isInkclip(e.port)) return + if (e.port?.state === 'connected') { + if (e.port.type === 'input' && !this.input) this.input = e.port as MIDIInput + else if (e.port.type === 'output' && !this.output) this.output = e.port as MIDIOutput + } else { + if (e.port.type === 'input' && e.port === this.input) this.input = null + else if (e.port.type === 'output' && e.port === this.output) this.output = null + } + }) + } } const DeviceContextToken = Symbol('device') @@ -66,7 +73,7 @@ export function getDeviceContext(): DeviceContext { return getContext(DeviceContextToken) } -export function createDeviceContext(): DeviceContext { - let ctx = new DeviceContext() +export function createDeviceContext(midi: MidiContext): DeviceContext { + let ctx = new DeviceContext(midi) return setContext(DeviceContextToken, ctx) } diff --git a/webapp/src/lib/contexts/midi.svelte.ts b/webapp/src/lib/contexts/midi.svelte.ts new file mode 100644 index 0000000..4e93d9d --- /dev/null +++ b/webapp/src/lib/contexts/midi.svelte.ts @@ -0,0 +1,72 @@ +import { getContext, setContext } from 'svelte' + +export class MidiContext { + // Value before initialize() should not be relied upon. 'granted' is used as + // initial value purely because it doesn't bring up a flash of dialog on + // page load. + perm: 'unsupported' | PermissionState = $state('granted') + midi: MIDIAccess | null = $state(null) + + async initialize() { + if (navigator.requestMIDIAccess == null) { + // This browser definitely doesn't support Web MIDI + this.perm = 'unsupported' + return + } + + // We decide how to acquire MIDI access based on likelyPerm: + // - 'granted': we should try implicitly acquiring MIDI access. + // - 'prompt': we should display a dialog explaining why we need MIDI + // before trying to acquire. + // - 'denied': we are denied MIDI access. + let likelyPerm: PermissionState + + if (navigator.permissions == null) { + // We don't know our permissions. Try to acquire implicitly nonetheless, + // since we think the advantage of auto-connect outweighs the browser + // prompting the user without warning + likelyPerm = 'granted' + } else { + try { + const status = await navigator.permissions.query({ + name: 'midi', + sysex: true, + } as PermissionDescriptor) + console.log('MIDI permission status:', status) + likelyPerm = status.state + status.addEventListener('change', () => (this.perm = status.state)) + } catch (e) { + // Non-compliant Permissions API. Since we know requestMIDIAccess is + // present, try acquiring implicitly nonetheless. + console.error('Error querying permission:', e) + likelyPerm = 'granted' + } + } + + if (likelyPerm === 'granted') await this.acquire() + else this.perm = likelyPerm + } + + // Attempt to acquire MIDI access. If permission isn't granted already, this + // will likely cause the browser to prompt the user. + async acquire() { + try { + this.midi = await navigator.requestMIDIAccess({ sysex: true }) + this.perm = 'granted' + } catch (e) { + // The user or OS denied us access, or no support + console.error('Error acquiring MIDI access:', e) + this.perm = 'denied' + } + } +} + +const MidiContextToken = Symbol('midi') + +export function getMidiContext(): MidiContext { + return getContext(MidiContextToken) +} + +export function createMidiContext(): MidiContext { + return setContext(MidiContextToken, new MidiContext()) +} diff --git a/webapp/src/lib/layouts/Main.svelte b/webapp/src/lib/layouts/Main.svelte index da2a948..73cc956 100644 --- a/webapp/src/lib/layouts/Main.svelte +++ b/webapp/src/lib/layouts/Main.svelte @@ -9,16 +9,21 @@ import { createConversionConfig } from '$lib/contexts/config.svelte' import { createFilesContext } from '$lib/contexts/files.svelte' import { createImageContext } from '$lib/contexts/image.svelte' import { createRenderedContext } from '$lib/contexts/rendered.svelte' +import { createMidiContext } from '$lib/contexts/midi.svelte' import { onMount } from 'svelte' +import PermissionRequestDialog from './dialog/PermissionRequestDialog.svelte' +import PermissionDeniedDialog from './dialog/PermissionDeniedDialog.svelte' +import UnsupportedDialog from './dialog/UnsupportedDialog.svelte' -const deviceCtx = createDeviceContext() +const midiCtx = createMidiContext() +createDeviceContext(midiCtx) const config = createConversionConfig() const filesCtx = createFilesContext() const imageCtx = createImageContext(filesCtx) createRenderedContext(imageCtx, config) -onMount(() => { - deviceCtx.initialize() +onMount(async () => { + await midiCtx.initialize() }) @@ -34,4 +39,8 @@ onMount(() => { + + + + diff --git a/webapp/src/lib/layouts/dialog/PermissionDeniedDialog.svelte b/webapp/src/lib/layouts/dialog/PermissionDeniedDialog.svelte new file mode 100644 index 0000000..e7590c0 --- /dev/null +++ b/webapp/src/lib/layouts/dialog/PermissionDeniedDialog.svelte @@ -0,0 +1,28 @@ + + + + + + + MIDI permission needed + + +

+ We detected that we are denied MIDI access. This means this tool cannot function properly. +

+

+ To use this tool, you will need to grant us MIDI permission manually in your browser + settings, and then reload this page. +

+
+
+
+
diff --git a/webapp/src/lib/layouts/dialog/PermissionRequestDialog.svelte b/webapp/src/lib/layouts/dialog/PermissionRequestDialog.svelte new file mode 100644 index 0000000..616e928 --- /dev/null +++ b/webapp/src/lib/layouts/dialog/PermissionRequestDialog.svelte @@ -0,0 +1,32 @@ + + + + + + + MIDI permission needed + + +

Write to Inkclip uses the Web MIDI API to talk to your devices.

+

+ Click on "I understand", and then follow your browser's prompt to grant us permission. +

+
+ + + midiCtx.acquire()}>I understand + +
+
+
diff --git a/webapp/src/lib/layouts/Unsupported.svelte b/webapp/src/lib/layouts/dialog/UnsupportedDialog.svelte similarity index 63% rename from webapp/src/lib/layouts/Unsupported.svelte rename to webapp/src/lib/layouts/dialog/UnsupportedDialog.svelte index 7766c07..bcf8a37 100644 --- a/webapp/src/lib/layouts/Unsupported.svelte +++ b/webapp/src/lib/layouts/dialog/UnsupportedDialog.svelte @@ -1,18 +1,17 @@ -
- -
Not Supported
-
- - + - + Browser not supported @@ -21,7 +20,8 @@ We recommend using a Chromium-based browser, such as a recent version of Google Chrome, Microsoft Edge, or - Opera. Firefox may also work, although we are aware of more issues there. + Opera. Firefox may also work, although we are aware + of more issues there.

diff --git a/webapp/src/lib/utils.svelte.ts b/webapp/src/lib/utils.svelte.ts new file mode 100644 index 0000000..682986c --- /dev/null +++ b/webapp/src/lib/utils.svelte.ts @@ -0,0 +1,11 @@ +import { untrack } from 'svelte' + +/** + * Like $effect(), but requires you to declare dependencies explicitly. Conceptually, `track` should be a pure function that returns the dependencies, and `action` is executed whenever these dependencies change. + */ +export function effect(track: () => unknown, action: () => unknown) { + $effect(() => { + track() + untrack(action) + }) +}