diff --git a/src/chrome/index.js b/src/chrome/index.js index 35f8f8b..2612283 100644 --- a/src/chrome/index.js +++ b/src/chrome/index.js @@ -52,7 +52,7 @@ function noopChrome() { getURL(url) { return url; }, - lastError: {message: ''}, + lastError: undefined, onConnect: noopEvent(), }, }; diff --git a/src/devtools/WebAudioEventObserver.test.js b/src/devtools/WebAudioEventObserver.test.js index 0c43361..70c4d08 100644 --- a/src/devtools/WebAudioEventObserver.test.js +++ b/src/devtools/WebAudioEventObserver.test.js @@ -3,7 +3,7 @@ import {beforeEach, describe, expect, it, jest} from '@jest/globals'; import {chrome} from '../chrome'; -import {Events} from '../chrome/DebuggerWebAudioDomain'; +import {Event} from '../chrome/DebuggerWebAudioDomain'; import {WebAudioEventObserver} from './WebAudioEventObserver'; @@ -53,12 +53,19 @@ describe('WebAudioEventObserver', () => { if (jest.isMockFunction(chrome.debugger.attach)) { /** @type {function} */ (chrome.debugger.attach.mock.calls[0][2])(); } + expect(chrome.debugger.sendCommand).toBeCalledTimes(1); + if (jest.isMockFunction(chrome.debugger.sendCommand)) { + /** @type {function} */ (chrome.debugger.sendCommand.mock.calls[0][2])(); + } unsubscribe(); expect(chrome.debugger.detach).toBeCalled(); + if (jest.isMockFunction(chrome.debugger.sendCommand)) { + /** @type {function} */ (chrome.debugger.sendCommand.mock.calls[1][2])(); + } + expect(chrome.debugger.sendCommand).toBeCalledTimes(2); if (jest.isMockFunction(chrome.debugger.detach)) { /** @type {function} */ (chrome.debugger.detach.mock.calls[0][1])(); } - expect(chrome.debugger.sendCommand).toBeCalledTimes(2); expect(chrome.debugger.onDetach.removeListener).toBeCalled(); expect(chrome.debugger.onEvent.removeListener).toBeCalled(); }); @@ -85,10 +92,10 @@ describe('WebAudioEventObserver', () => { if (jest.isMockFunction(chrome.debugger.onEvent.addListener)) { /** @type {function} */ ( chrome.debugger.onEvent.addListener.mock.calls[0][0] - )('tab', Events.contextCreated, contextCreated); + )('tab', Event.contextCreated, contextCreated); } expect(nextMock).toBeCalledWith({ - method: Events.contextCreated, + method: Event.contextCreated, params: contextCreated, }); }); diff --git a/src/devtools/WebAudioEventObserver.ts b/src/devtools/WebAudioEventObserver.ts index b34d7ee..a4bb822 100644 --- a/src/devtools/WebAudioEventObserver.ts +++ b/src/devtools/WebAudioEventObserver.ts @@ -1,5 +1,21 @@ +import { + BehaviorSubject, + catchError, + concat, + defer, + distinctUntilChanged, + map, + merge, + Observable, + ObservedValueOf, + of, + scan, + share, + tap, +} from 'rxjs'; + import {chrome} from '../chrome'; -import {Methods} from '../chrome/DebuggerWebAudioDomain'; +import {Method} from '../chrome/DebuggerWebAudioDomain'; import {Observer} from '../utils/Observer'; import {Audion} from './Types'; @@ -11,6 +27,10 @@ const {tabId} = chrome.devtools.inspectedWindow; * @alias WebAudioEventObserver */ export class WebAudioEventObserver extends Observer { + permission: PermissionSubject; + attachInterest: CounterSubject; + eventInterest: CounterSubject; + /** * Observe WebAudio events from chrome.debugger. */ @@ -33,19 +53,295 @@ export class WebAudioEventObserver extends Observer { chrome.debugger.onDetach.addListener(onDetach); chrome.debugger.onEvent.addListener(onEvent); + this.attachInterest.increment(); + this.eventInterest.increment(); + return () => { chrome.debugger.onDetach.removeListener(onDetach); chrome.debugger.onEvent.removeListener(onEvent); - chrome.debugger.sendCommand({tabId}, Methods.disable); - chrome.debugger.detach({tabId}, () => {}); + + this.eventInterest.decrement(); + this.attachInterest.decrement(); }; }); + + const permission = (this.permission = new PermissionSubject()); + + const attachInterest = (this.attachInterest = new CounterSubject(0)); + + const attachSubject = new AttachStateSubject(); + + const eventInterest = (this.eventInterest = new CounterSubject(0)); + + const eventSubject = new EventStateSubject(); + + const debuggerState = merge( + permission.pipe(map((permission) => ({permission}))), + attachInterest.pipe(map((attachInterest) => ({attachInterest}))), + attachSubject.pipe(map((attachState) => ({attachState}))), + eventInterest.pipe(map((eventInterest) => ({eventInterest}))), + eventSubject.pipe(map((eventState) => ({eventState}))), + ).pipe( + scan((accum, value) => ({...accum, ...value}), { + permission: AttachPermission.UNKNOWN, + attachInterest: 0, + attachState: AttachState.DETACHED, + eventInterest: 0, + eventState: EventState.DISABLED, + }), + distinctUntilChanged( + (previous, current) => + previous.permission === current.permission && + previous.attachInterest === current.attachInterest && + previous.attachState === current.attachState && + previous.eventInterest === current.eventInterest && + previous.eventState === current.eventState, + ), + share(), + ); + + debuggerState.subscribe({ + next: (state) => { + if ( + state.permission === AttachPermission.TEMPORARY && + state.attachInterest > 0 + ) { + attachSubject.attach(); + } else { + attachSubject.detach(); + } + }, + }); + + onDebuggerDetach.subscribe({ + next([, reason]) { + if (reason === 'canceled_by_user') { + permission.reject(); + } + attachSubject.next(AttachState.DETACHED); + }, + }); + + debuggerState.subscribe({ + next(state) { + if ( + state.attachState === AttachState.ATTACHED && + state.eventInterest > 0 + ) { + eventSubject.enable(); + } else { + if (state.attachState === AttachState.ATTACHED) { + eventSubject.disable(); + } else { + eventSubject.next(EventState.DISABLED); + } + } + }, + }); } /** Attaches the chrome.debugger to start observing events. */ attach() { - chrome.debugger.attach({tabId}, debuggerVersion, () => { - chrome.debugger.sendCommand({tabId}, Methods.enable); - }); + this.permission.grantTemporary(); } } + +enum AttachState { + DETACHING = 'detaching', + DETACHED = 'detached', + ATTACHING = 'attaching', + ATTACHED = 'attached', +} + +function bindChromeCallback

( + method: (...args: [...params: P, callback: () => void]) => void, + thisArg = null, +) { + return (...args: P) => + new Observable((subscriber) => { + method.call(thisArg, ...args, () => { + if (chrome.runtime.lastError) { + subscriber.error(chrome.runtime.lastError); + } else { + subscriber.complete(); + } + }); + }); +} +function fromChromeEvent( + event: Chrome.Event<(...args: A) => any>, +) { + return new Observable((subscriber) => { + const listener = (...args: A) => { + subscriber.next(args); + }; + event.addListener(listener); + return () => { + event.removeListener(listener); + }; + }); +} + +const attach = bindChromeCallback(chrome.debugger.attach, chrome.debugger); +const detach = bindChromeCallback(chrome.debugger.detach, chrome.debugger); +const sendCommand = bindChromeCallback( + chrome.debugger.sendCommand, + chrome.debugger, +); + +const onDebuggerEvent = fromChromeEvent< + [target: Chrome.DebuggerDebuggee, method: Method, params: any] +>(chrome.debugger.onEvent); +const onDebuggerDetach = fromChromeEvent< + [target: Chrome.DebuggerDebuggee, reason: string] +>(chrome.debugger.onDetach); + +enum AttachPermission { + UNKNOWN, + TEMPORARY, + REJECTED, +} + +enum EventState { + DISABLING, + DISABLED, + ENABLING, + ENABLED, +} + +class PermissionSubject extends BehaviorSubject { + constructor() { + super(AttachPermission.UNKNOWN); + } + + grantTemporary() { + if (this.value === AttachPermission.UNKNOWN) { + this.next(AttachPermission.TEMPORARY); + } + } + + reject() { + if (this.value !== AttachPermission.REJECTED) { + this.next(AttachPermission.REJECTED); + } + } +} + +abstract class TransitionSubject extends BehaviorSubject { + transition(options: { + expectState: T; + transitionState: T; + finalState: T; + errorState?: T; + transit: () => Observable; + }) { + if (this.value === options.expectState) { + concat( + of(options.transitionState), + options.transit(), + defer(() => + this.value === options.transitionState + ? of(options.finalState) + : of(), + ), + ) + .pipe( + catchError((err) => + of( + this.value === options.transitionState + ? options.errorState || options.expectState + : options.expectState, + ), + ), + ) + .subscribe({next: this.next.bind(this)}); + } + } +} + +class AttachStateSubject extends TransitionSubject { + private readonly attachTransition = { + expectState: AttachState.DETACHED, + transitionState: AttachState.ATTACHING, + transit: () => attach({tabId}, debuggerVersion), + finalState: AttachState.ATTACHED, + }; + + private readonly detachTransition = { + expectState: AttachState.ATTACHED, + transitionState: AttachState.DETACHING, + finalState: AttachState.DETACHED, + transit: () => detach({tabId}), + }; + + constructor() { + super(AttachState.DETACHED); + } + + attach() { + this.transition(this.attachTransition); + } + + detach() { + this.transition(this.detachTransition); + } +} + +class EventStateSubject extends TransitionSubject { + private readonly enableTransition = { + expectState: EventState.DISABLED, + transitionState: EventState.ENABLING, + finalState: EventState.ENABLED, + transit: () => sendCommand({tabId}, Method.enable), + }; + + private readonly disableTransition = { + expectState: EventState.ENABLED, + transitionState: EventState.DISABLING, + finalState: EventState.DISABLED, + errorState: EventState.DISABLED, + transit: () => sendCommand({tabId}, Method.disable), + }; + + constructor() { + super(EventState.DISABLED); + } + + enable() { + this.transition(this.enableTransition); + } + + disable() { + this.transition(this.disableTransition); + } +} + +class CounterSubject extends BehaviorSubject { + increment() { + this.next(this.value + 1); + } + + decrement() { + this.next(this.value - 1); + } +} + +function props< + T extends {[key: string]: Observable}, + R extends {[key in keyof T]: ObservedValueOf}, +>( + source: T, + initialValue: R = (Object.keys(source) as (keyof T)[]).reduce( + (accum, key) => { + accum[key] = undefined; + return accum; + }, + {} as R, + ), +): Observable { + return merge( + ...Object.entries(source).map(([key, property]) => + property.pipe(map((value) => ({[key]: value}))), + ), + ).pipe(scan((accum, value) => ({...accum, ...value}), initialValue)); +}