Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion demo/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ const ISOLATED_PARTITION = "demo-isolated";
/** The demo's own settings and their defaults, which the kit keeps beside its own. */
const SETTINGS_DEFAULTS = Object.freeze({
showAccentSample: true,
// The folder the demo saves its exports in, null for the system's
// Documents folder. Main alone changes it (MAIN_ONLY_SETTINGS), as an app
// whose folder is chosen with the system's folder picker would: the page
// may read it, and is refused a change to it.
exportFolder: null,
});

module.exports = { IPC, ISOLATED_PARTITION, SETTINGS_DEFAULTS };
/** The demo's settings only main changes. */
const MAIN_ONLY_SETTINGS = Object.freeze(["exportFolder"]);

module.exports = { IPC, ISOLATED_PARTITION, SETTINGS_DEFAULTS, MAIN_ONLY_SETTINGS };
12 changes: 8 additions & 4 deletions demo/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,25 @@
// from kit.sessions.isolated(), where the kit's preload is never registered:
// it gets electronAPI only, and its own channel is answered there only.
//
// The demo passes the kit its own setting (and a migration), its credits, its
// own menu item and a log in a file, so every piece of the kit is in use. It
// logs what it was opened with, which the log keeps without any folder.
// The demo passes the kit its own settings (one only main changes, and a
// migration), its credits, its own menu item and a log in a file, so every
// piece of the kit is in use. It logs what it was opened with, which the log
// keeps without any folder.

const path = require("path");
const { BrowserWindow } = require("electron");
const { IPC, ISOLATED_PARTITION, SETTINGS_DEFAULTS } = require("./constants");
const { IPC, ISOLATED_PARTITION, SETTINGS_DEFAULTS, MAIN_ONLY_SETTINGS } = require("./constants");

const kit = require("@diamonddigitaldev/electron-kit/main").start({
// package.json's build.appId: the Windows user model ID its shortcut carries.
appId: "com.diamonddigitaldev.electronkitdemo",
// The export folder is main's alone, and a whole path when it's set.
// Version 1 of the demo's settings: a store from before it loses the
// setting the demo dropped, and its old key.
settings: {
defaults: SETTINGS_DEFAULTS,
mainOnly: MAIN_ONLY_SETTINGS,
checks: { exportFolder: (folder) => folder === null || (typeof folder === "string" && path.isAbsolute(folder)) },
version: 1,
migrate: (settings) => {
delete settings.showGrid;
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ and links here, so these can be corrected without a new release.
| [IPC](ipc.md) | The shared channels behind `window.kitAPI`, links and the allowlist, and `kit.ipc.handle()` for an app's own, each answering the app's own page only |
| [The Shell, Settings and Credits](shell.md) | `kit.ui.mountShell()`: the nav rail, the header, the bottom bar and its panels, and the Settings view with its Update and Credits tabs |
| [Page Components](page-components.md) | Toasts, prompts, form dialogs and the house's form row, the parts of a section of files (drop zone, progress, action bar), cards, the status line and busy buttons, the switch and the stepped slider, the key guard, `kit.format`, and the copy rules |
| [The Main Process](main-process.md) | The main window, isolated sessions, spell checking, one instance and the files an app is opened with, the settings and their migration, the redacted log, and the menu |
| [The Main Process](main-process.md) | The main window, isolated sessions, spell checking, one instance and the files an app is opened with, the settings (those only main changes, their checks, hearing a change) and their migration, the redacted log, and the menu |
| [Updates](updates.md) | The updater: when it checks, Stable, Beta and Alpha, automatic downloads, its state, and a proxy |
| [Building](building.md) | `config()`, the electron-builder config every app builds with; the files an app opens, on Linux; the update files every release carries; and naming an app's files |
| [Theme, Accent and Tokens](theming.md) | The OS theme, an app's accent and its WCAG 2.2 AA check, the house tokens, focus rings and controls |
Expand Down
36 changes: 36 additions & 0 deletions docs/main-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,42 @@ only, or it's refused and nothing is stored. A setting whose default is `null` m
takes any JSON value (File Converter's `concurrency: null`, the CPU count until someone picks one). The main process has the same settings as
`kit.settings.get()` and `kit.settings.set(changes)`.

**Settings only main changes, and checks.** A setting that names a folder on disk, such as where downloads are
saved, shouldn't be the page's to point anywhere: it's chosen in main, with the system's folder picker. And a
setting may take only some values of its kind, such as a number in a range:

```js
settings: {
defaults: { downloadFolder: null, transfersAtOnce: 3 },
mainOnly: ["downloadFolder"], // the page reads it, and can't change it
checks: { transfersAtOnce: (n) => Number.isInteger(n) && n >= 1 && n <= 6 }, // true for a value it takes
},
```

- **`mainOnly`:** a change from the page (`kitAPI.setSettings()`) naming one of them is refused whole ("The
setting "downloadFolder" can't be changed from the page."), and nothing of it is stored. `kit.settings.set()`
in main changes them as any other, and the page reads them as it reads the rest.
- **`checks`:** a function per setting, handed a copy of the value once its kind is checked. Unless it returns
`true`, the change is refused whole, from the page or from main ("The setting "transfersAtOnce" doesn't take
that value."), and nothing of it is stored. What's stored is read as it is, unchecked.
- Both name the app's own settings only, keys of `defaults`, never the kit's; a check is a function. `start()`
throws on anything else.

**Hearing a change.** `kit.settings.onChange(listener)` hears every change stored, the page's and main's, and
returns the function that stops it:

```js
const stop = kit.settings.onChange((changes, after, { from }) => {
if (Object.hasOwn(changes, "transfersAtOnce")) transfers.setAtOnce(after.transfersAtOnce);
});
```

`changes` is what was changed, `after` every setting as stored, and `from` `"page"` or `"main"` (the updater
saving the running build's channel, the first time, is main's). Each listener has its own copies. It's told
once the change is stored and the kit has acted on it (the updater has its new channel, the log is on disk or
off), in the order the listeners were added. A refused change tells no one. A listener that throws, or returns
a promise that rejects, is logged as an error: the change stays stored, and the others still hear it.

**Migration.** An app whose settings change shape between versions numbers them:

```js
Expand Down
43 changes: 42 additions & 1 deletion e2e/log-store.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
// log in debug.log in its profile (userData), started with a banner and
// emptied at each launch, with what it was opened with logged and no folder
// in it; and a profile from before the demo's settings version 1 is migrated
// once, as the store is first opened, before the page reads anything.
// once, as the store is first opened, before the page reads anything. The
// demo's export folder is main's alone: the page is refused a change to it,
// main's is taken (and checked), and main hears each change.

const fs = require("fs");
const path = require("path");
Expand Down Expand Up @@ -49,6 +51,45 @@ test("a profile from before the settings' version is migrated once, before the p
expect(log).toContain("[INFO] Settings version 1: removing the store's obsolete key recentFiles.");
});

test("the page is refused a change to the export folder, which main alone changes; main's is taken, checked, and heard", async ({ demo }) => {
const main = await demo.mainWindow();
const config = path.join(demo.profile, "config.json");
/** What a kitAPI.setSettings() comes to in the page: the setting asked for, or the error it rejects with. */
const pageSets = (changes, key) => main.evaluate(([changes, key]) => window.kitAPI.setSettings(changes).then((after) => after[key], (err) => err.message), [changes, key]);
/** What kit.settings.set() comes to in main: the setting asked for, or the error it throws. */
const mainSets = (changes, key) => demo.app.evaluate((_electron, [changes, key]) => {
try {
return globalThis.demoKit.settings.set(changes)[key];
} catch (err) {
return err.message;
}
}, [changes, key]);
const read = () => main.evaluate(() => window.kitAPI.getSettings());

// Main hears every change, and who made it.
await demo.app.evaluate(() => {
globalThis.heard = [];
globalThis.demoKit.settings.onChange((changes, _after, { from }) => globalThis.heard.push({ changes, from }));
});

expect(await pageSets({ showAccentSample: false, exportFolder: demo.profile }, "exportFolder")).toContain(`The setting "exportFolder" can't be changed from the page.`);
expect(await read(), "nothing of that change is stored").toMatchObject({ showAccentSample: true, exportFolder: null });
expect(JSON.parse(fs.readFileSync(config, "utf8")).settings ?? {}).not.toHaveProperty("showAccentSample", false);

// Main sets it, and the page reads it; a folder that isn't a whole path is refused by the demo's check.
expect(await mainSets({ exportFolder: demo.profile }, "exportFolder")).toBe(demo.profile);
expect(await mainSets({ exportFolder: "exports" }, "exportFolder")).toBe(`The setting "exportFolder" doesn't take that value.`);
expect((await read()).exportFolder).toBe(demo.profile);
expect(JSON.parse(fs.readFileSync(config, "utf8")).settings.exportFolder).toBe(demo.profile);

// The page still changes the demo's other setting.
expect(await pageSets({ showAccentSample: false }, "showAccentSample")).toBe(false);
expect(await demo.app.evaluate(() => globalThis.heard)).toEqual([
{ changes: { exportFolder: demo.profile }, from: "main" },
{ changes: { showAccentSample: false }, from: "page" },
]);
});

test.describe("the memory log", () => {
test.use({ demoEnv: { KIT_DEMO_LOG: "memory" } });

Expand Down
48 changes: 34 additions & 14 deletions main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ function registerPreload(ses) {
/**
* Start the kit. Call it once, at the top of main.js, before any window.
* @param {{
* settings?: { defaults?: Record<string, unknown>, version?: number, migrate?: (settings: object, from: number) => object, obsoleteKeys?: string[] },
* settings?: { defaults?: Record<string, unknown>, mainOnly?: string[], checks?: Record<string, (value: unknown) => boolean>, version?: number, migrate?: (settings: object, from: number) => object, obsoleteKeys?: string[] },
* log?: "file" | "memory" | { mode: "file" | "memory", lines?: number },
* appId?: string,
* singleInstance?: boolean,
Expand All @@ -86,9 +86,11 @@ function registerPreload(ses) {
* keeps the log in debug.log in userData; "memory" keeps the run's last
* lines (1000, or lines) in memory, and in debug.log too only while the
* kit's keepLogOnDisk setting is on (log.js); without it, the console
* only. settings.version, migrate and obsoleteKeys: the store's migration
* (store.js). singleInstance: false lets more than one run (it's one, by
* default). appId: the app's build.appId, its user model ID on Windows
* only. settings.mainOnly: the app's settings the page may read and not
* change; settings.checks: a function per setting, true for a value it
* takes, from the page or main (store.js). settings.version, migrate and
* obsoleteKeys: the store's migration (store.js). singleInstance: false
* lets more than one run (it's one, by default). appId: the app's build.appId, its user model ID on Windows
* (instance.js); the app's name without it. files: the app takes files
* it's opened with (instance.js); files.except, the switches that make a
* launch the app's own to handle, whose files aren't taken.
Expand All @@ -97,7 +99,7 @@ function registerPreload(ses) {
* (shell.js).
* @returns {{
* ready: Promise<void>,
* settings: { get(): object, set(changes: object): object },
* settings: { get(): object, set(changes: object): object, onChange(listener: (changes: object, after: object, how: { from: "page" | "main" }) => void): () => void },
* ipc: { handle(channel: string, handler: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any, options?: { session?: Electron.Session }): void },
* sessions: { isolated(name: string, options?: { persist?: boolean }): Electron.Session },
* log: { error(...args: unknown[]): void, warn(...args: unknown[]): void, info(...args: unknown[]): void, debug(...args: unknown[]): void, lines(): string[] },
Expand All @@ -108,9 +110,11 @@ function registerPreload(ses) {
* }}
* ready resolves once the app is ready, the shared preload is registered and
* the menu set: create windows after it. settings are the app's settings,
* for its main process. ipc.handle() answers one of the app's own channels,
* for the app's own page only (ipc.js), in the UI session or in the
* isolated session given. sessions.isolated() makes a session without the
* for its main process; settings.onChange() hears every stored change,
* after the updater and the log have, and returns the function that stops
* it. ipc.handle() answers one of the app's own channels, for the app's
* own page only (ipc.js), in the UI session or in the isolated session
* given. sessions.isolated() makes a session without the
* kit's bridge (sessions.js). log is the app's log, redacted, and its
* lines() the banner and the run's last lines (log.js).
* windows.createMain() makes the main window (windows.js).
Expand Down Expand Up @@ -140,6 +144,7 @@ function start(config = {}) {
banner: `${config.name ?? app.getName()} ${app.getVersion()} started`,
});
const settings = store.createSettings({ ...config.settings, log, memoryLog: mode === "memory" });
const changed = store.createChangeListeners(log);
const credits = info.checkInfo({ credits: config.credits, repository: config.repository, name: config.name });
const openExternal = shell.createOpener({
allow: shell.checkOpenExternal(config.openExternal),
Expand All @@ -149,7 +154,16 @@ function start(config = {}) {
return [...shown.credits.lines.flat().map((part) => part?.href ?? null), shown.credits.donate, shown.repository];
},
});
const updates = updater.createUpdater({ options: updater.checkUpdates(config.updates), app, settings, send: sendUpdateStatus, log });
// The updater saves the running build's channel itself, the first time: the app's listeners hear that too.
const updaterSettings = {
get: settings.get,
set: (changes) => {
const after = settings.set(changes);
changed.tell(changes, after, "main");
return after;
},
};
const updates = updater.createUpdater({ options: updater.checkUpdates(config.updates), app, settings: updaterSettings, send: sendUpdateStatus, log });
const template = menu.menuTemplate({
items: config.menu?.items,
version: app.getVersion(),
Expand All @@ -163,19 +177,25 @@ function start(config = {}) {
windows = windowing.createWindows({ bounds: settings.bounds, onMainCreated: files.mainCreated });
files.listen({ isPackaged: app.isPackaged, appPath: app.getAppPath() });

/** Change some settings, from the page or the app's main, and tell the updater. */
function setSettings(changes) {
/**
* Change some settings, from the page or the app's main; tell the updater
* and the log, then the app's listeners.
* @param {Record<string, unknown>} changes
* @param {"page" | "main"} from
*/
function setSettings(changes, from) {
const before = settings.get();
const after = settings.set(changes);
const after = settings.set(changes, { from });
updates.settingsChanged(changes, before);
if (log.mode === "memory" && Object.hasOwn(changes, "keepLogOnDisk")) log.keepOnDisk(after.keepLogOnDisk);
changed.tell(changes, after, from);
return after;
}

ipc.handle(INVOKE.APP_GET_VERSION, () => app.getVersion());
ipc.handle(INVOKE.APP_GET_INFO, () => info.appInfo(credits));
ipc.handle(INVOKE.SETTINGS_GET, () => settings.get());
ipc.handle(INVOKE.SETTINGS_SET, (_event, changes) => setSettings(changes));
ipc.handle(INVOKE.SETTINGS_SET, (_event, changes) => setSettings(changes, "page"));
ipc.handle(INVOKE.SHELL_OPEN_EXTERNAL, (_event, url) => openExternal(url));
ipc.handle(INVOKE.UPDATE_GET_STATUS, () => updates.status());
ipc.handle(INVOKE.UPDATE_CHECK, () => updates.check());
Expand Down Expand Up @@ -211,7 +231,7 @@ function start(config = {}) {
});
return {
ready,
settings: { get: settings.get, set: setSettings },
settings: { get: settings.get, set: (changes) => setSettings(changes, "main"), onChange: changed.onChange },
ipc: { handle: (channel, handler, options) => ipc.handleApp(channel, handler, options, isolation.isIsolated) },
sessions: { isolated: isolation.isolated },
log: { error: log.error, warn: log.warn, info: log.info, debug: log.debug, lines: log.lines },
Expand Down
Loading
Loading