Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: sync version download statuses between windows #1391

Merged
merged 13 commits into from
Jul 17, 2023
Merged
19 changes: 19 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,22 @@ export enum WindowSpecificSetting {
gitHubPublishAsPublic = 'gitHubPublishAsPublic',
version = 'version',
}

export interface AppStateBroadcastChannel extends BroadcastChannel {
postMessage(params: AppStateBroadcastMessage): void;
}

export type AppStateBroadcastMessage =
| {
type: AppStateBroadcastMessageType.isDownloadingAll;
payload: boolean;
}
| {
type: AppStateBroadcastMessageType.syncVersions;
payload: RunnableVersion[];
};

export enum AppStateBroadcastMessageType {
isDownloadingAll = 'isDownloadingAll',
syncVersions = 'syncVersions',
}
95 changes: 88 additions & 7 deletions src/renderer/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
} from 'mobx';

import {
AppStateBroadcastChannel,
AppStateBroadcastMessage,
AppStateBroadcastMessageType,
BlockableAccelerator,
ElectronReleaseChannel,
GenericDialogOptions,
Expand Down Expand Up @@ -66,10 +69,8 @@ export class AppState {
timeStyle: 'medium',
});

private settingKeyTypeGuard(key: never): never {
throw new Error(
`Unhandled setting ${key}, please handle it in the \`AppState\`.`,
);
private genericTypeGuard(_: never, errorMessage: string): never {
throw new Error(errorMessage);
}

// -- Persisted settings ------------------
Expand Down Expand Up @@ -209,8 +210,24 @@ export class AppState {
versions: this.baseVersions,
});

// Used for communications between windows
private broadcastChannel: AppStateBroadcastChannel = new BroadcastChannel(
'AppState',
);

// Notifies other windows that this version has changed so they can update their state to reflect that.
private broadcastVersionStates(versions: RunnableVersion[]) {
this.broadcastChannel.postMessage({
type: AppStateBroadcastMessageType.syncVersions,

// the RunnableVersion proxies can't be cloned by structuredClone,
// so we have to create plain objects out of them
payload: versions.map((version) => ({ ...version })),
});
}

constructor(versions: RunnableVersion[]) {
makeObservable<AppState, 'setPageHash'>(this, {
makeObservable<AppState, 'setPageHash' | 'setVersionStates'>(this, {
Bisector: observable,
acceleratorsToBlock: observable,
activeGistAction: observable,
Expand Down Expand Up @@ -275,6 +292,7 @@ export class AppState {
setPageHash: action,
setTheme: action,
setVersion: action,
setVersionStates: action,
showChannels: action,
showConfirmDialog: action,
showErrorDialog: action,
Expand Down Expand Up @@ -433,7 +451,10 @@ export class AppState {
}

default: {
this.settingKeyTypeGuard(key);
this.genericTypeGuard(
key,
`Unhandled setting "${key}", please handle it in the \`AppState\`.`,
);
}
}
} else if (
Expand All @@ -447,6 +468,36 @@ export class AppState {
}
});

/**
* Handles communications between windows.
*/
this.broadcastChannel.addEventListener(
'message',
(event: MessageEvent<AppStateBroadcastMessage>) => {
const { type, payload } = event.data;

switch (type) {
case AppStateBroadcastMessageType.isDownloadingAll: {
this.isDownloadingAll = payload;
break;
}

case AppStateBroadcastMessageType.syncVersions: {
this.setVersionStates(payload);
dsanders11 marked this conversation as resolved.
Show resolved Hide resolved

break;
}

default: {
this.genericTypeGuard(
type,
`Unhandled BroadcastChannel message "${type}", please handle it in the \`AppState\`.`,
);
}
}
},
);

// Setup auto-runs
autorun(() => this.save(GlobalSetting.theme, this.theme));
autorun(() =>
Expand Down Expand Up @@ -601,10 +652,18 @@ export class AppState {

public startDownloadingAll() {
this.isDownloadingAll = true;
this.broadcastChannel.postMessage({
type: AppStateBroadcastMessageType.isDownloadingAll,
payload: true,
});
}

public stopDownloadingAll() {
this.isDownloadingAll = false;
this.broadcastChannel.postMessage({
type: AppStateBroadcastMessageType.isDownloadingAll,
payload: false,
});
}

public startDeletingAll() {
Expand Down Expand Up @@ -717,6 +776,15 @@ export class AppState {
for (const ver of versions) {
this.versions[ver.version] ||= ver;
}

this.broadcastVersionStates(versions);
}

// Updates the version states in the current window to reflect updates made by other windows.
private setVersionStates(versions: RunnableVersion[]) {
for (const ver of versions) {
this.versions[ver.version] = ver;
}
}

/**
Expand Down Expand Up @@ -753,6 +821,8 @@ export class AppState {
};

await typeDefsCleaner();

this.broadcastVersionStates([ver]);
}
} else {
console.log(`State: Version ${version} already removed, doing nothing`);
Expand Down Expand Up @@ -791,22 +861,33 @@ export class AppState {

console.log(`State: Downloading Electron ${version}`);

this.broadcastVersionStates([
{
...ver,
state: InstallState.downloading,
},
]);

// Download the version without setting it as the current version.
await this.installer.ensureDownloaded(version, {
mirror: {
electronMirror,
electronNightlyMirror,
},
progressCallback(progress: ProgressObject) {
progressCallback: (progress: ProgressObject) => {
// https://mobx.js.org/actions.html#runinaction
runInAction(() => {
const percent = Math.round(progress.percent * 100) / 100;
if (ver.downloadProgress !== percent) {
ver.downloadProgress = percent;

this.broadcastVersionStates([ver]);
}
});
},
});

this.broadcastVersionStates([ver]);
}

/**
Expand Down
54 changes: 54 additions & 0 deletions tests/renderer/state-spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IReactionDisposer, reaction } from 'mobx';

import {
AppStateBroadcastMessageType,
BlockableAccelerator,
ElectronReleaseChannel,
GenericDialogType,
Expand Down Expand Up @@ -46,6 +47,7 @@ describe('AppState', () => {
let removeSpy: jest.SpyInstance;
let installSpy: jest.SpyInstance;
let ensureDownloadedSpy: jest.SpyInstance;
let broadcastMessageSpy: jest.SpyInstance;

beforeEach(() => {
({ mockVersions, mockVersionsArray } = new VersionsMock());
Expand All @@ -65,6 +67,10 @@ describe('AppState', () => {
ensureDownloadedSpy = jest
.spyOn(appState.installer, 'ensureDownloaded')
.mockResolvedValue({ path: '', alreadyExtracted: false });
broadcastMessageSpy = jest.spyOn(
(appState as any).broadcastChannel,
'postMessage',
);
});

it('exists', () => {
Expand Down Expand Up @@ -96,6 +102,10 @@ describe('AppState', () => {
const newCount = Object.keys(appState.versions).length;
expect(newCount).toBe(oldCount + 1);
expect(appState.versions[version]).toStrictEqual(ver);
expect(broadcastMessageSpy).toHaveBeenCalledWith({
type: AppStateBroadcastMessageType.syncVersions,
payload: [ver],
});
});
});

Expand Down Expand Up @@ -300,22 +310,30 @@ describe('AppState', () => {

it('does not remove the active version', async () => {
const ver = appState.versions[active];
broadcastMessageSpy.mockClear();
await appState.removeVersion(ver);
expect(removeSpy).not.toHaveBeenCalled();
expect(broadcastMessageSpy).not.toHaveBeenCalled();
});

it('removes a version', async () => {
const ver = appState.versions[version];
ver.state = InstallState.installed;
await appState.removeVersion(ver);
expect(removeSpy).toHaveBeenCalledWith(ver.version);
expect(broadcastMessageSpy).toHaveBeenCalledWith({
type: AppStateBroadcastMessageType.syncVersions,
payload: [ver],
});
});

it('does not remove it if not necessary', async () => {
const ver = appState.versions[version];
ver.state = InstallState.missing;
broadcastMessageSpy.mockClear();
await appState.removeVersion(ver);
expect(removeSpy).toHaveBeenCalledTimes(0);
expect(broadcastMessageSpy).not.toHaveBeenCalled();
});

it('removes (but does not delete) a local version', async () => {
Expand All @@ -326,11 +344,13 @@ describe('AppState', () => {
ver.source = VersionSource.local;
ver.state = InstallState.installed;

broadcastMessageSpy.mockClear();
await appState.removeVersion(ver);

expect(saveLocalVersions).toHaveBeenCalledTimes(1);
expect(appState.versions[version]).toBeUndefined();
expect(removeSpy).toHaveBeenCalledTimes(0);
expect(broadcastMessageSpy).not.toHaveBeenCalled();
});
});

Expand All @@ -343,16 +363,22 @@ describe('AppState', () => {

expect(ensureDownloadedSpy).toHaveBeenCalled();
expect(installSpy).not.toHaveBeenCalled();
expect(broadcastMessageSpy).toHaveBeenCalledWith({
type: AppStateBroadcastMessageType.syncVersions,
payload: [ver],
});
});

it('does not download a version if already ready', async () => {
const ver = appState.versions['2.0.2'];
ver.state = InstallState.installed;

broadcastMessageSpy.mockClear();
await appState.downloadVersion(ver);

expect(ensureDownloadedSpy).not.toHaveBeenCalled();
expect(installSpy).not.toHaveBeenCalled();
expect(broadcastMessageSpy).not.toHaveBeenCalled();
});
});

Expand Down Expand Up @@ -749,6 +775,10 @@ describe('AppState', () => {
appState.isDownloadingAll = false;
appState.startDownloadingAll();
expect(appState.isDownloadingAll).toBe(true);
expect(broadcastMessageSpy).toHaveBeenCalledWith({
type: AppStateBroadcastMessageType.isDownloadingAll,
payload: true,
});
});

it('takes no action when isDownloadingAll is true', () => {
Expand All @@ -763,6 +793,10 @@ describe('AppState', () => {
appState.isDownloadingAll = true;
appState.stopDownloadingAll();
expect(appState.isDownloadingAll).toBe(false);
expect(broadcastMessageSpy).toHaveBeenCalledWith({
type: AppStateBroadcastMessageType.isDownloadingAll,
payload: false,
});
});

it('takes no action when isDownloadingAll is false', () => {
Expand Down Expand Up @@ -799,4 +833,24 @@ describe('AppState', () => {
expect(appState.isDeletingAll).toBe(false);
});
});

describe('broadcastChannel', () => {
it('updates the version state in response to changes in other windows', async () => {
const fakeVersion = {
version: '13.9.9',
state: InstallState.downloading,
} as RunnableVersion;

expect(appState.versions[fakeVersion.version]).toBeFalsy();

(appState as any).broadcastChannel.postMessage({
type: AppStateBroadcastMessageType.syncVersions,
payload: [fakeVersion],
});

expect(appState.versions[fakeVersion.version].state).toEqual(
InstallState.downloading,
);
});
});
});
25 changes: 25 additions & 0 deletions tests/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,31 @@ jest.mock('@sentry/electron/renderer', () => ({
init: jest.fn(),
}));

class FakeBroadcastChannel extends EventTarget {
constructor(channelName) {
super();
this.name = channelName;
}

postMessage(message) {
this.dispatchEvent(new MessageEvent('message', { data: message }));
}
}

global.BroadcastChannel = class Singleton {
static channels = new Map();

constructor(channelName) {
if (!Singleton.channels.has(channelName)) {
Singleton.channels.set(
channelName,
new FakeBroadcastChannel(channelName),
);
}
return Singleton.channels.get(channelName);
}
};

expect.addSnapshotSerializer(createSerializer({ mode: 'deep' }));

// We want to detect jest sometimes
Expand Down