diff --git a/packages/debugger-shell/src/electron/MainInstanceEntryPoint.js b/packages/debugger-shell/src/electron/MainInstanceEntryPoint.js index c756f6421590..14a8084a1ab6 100644 --- a/packages/debugger-shell/src/electron/MainInstanceEntryPoint.js +++ b/packages/debugger-shell/src/electron/MainInstanceEntryPoint.js @@ -10,9 +10,11 @@ // $FlowFixMe[unclear-type] We have no Flow types for the Electron API. const {BrowserWindow, Menu, app, shell, ipcMain} = require('electron') as any; +const SettingsStore = require('./SettingsStore.js'); const path = require('path'); const util = require('util'); +const appSettings = new SettingsStore(); const windowMetadata = new WeakMap< typeof BrowserWindow, $ReadOnly<{ @@ -53,10 +55,11 @@ function handleLaunchArgs(argv: string[]) { }, 1000); } } else { - // Create the browser window. frontendWindow = new BrowserWindow({ - width: 1200, - height: 600, + ...(getSavedWindowPosition(windowKey) ?? { + width: 1200, + height: 600, + }), webPreferences: { partition: 'persist:react-native-devtools', preload: require.resolve('./preload.js'), @@ -66,6 +69,8 @@ function handleLaunchArgs(argv: string[]) { }); // Auto-hide the Windows/Linux menu bar frontendWindow.setMenuBarVisibility(false); + // Observe and update saved window position + setupWindowResizeListeners(frontendWindow, windowKey); } // Open links in the default browser instead of in new Electron windows. @@ -119,6 +124,37 @@ function configureAppMenu() { Menu.setApplicationMenu(menu); } +function getSavedWindowPosition( + windowKey: string, +): ?{width: number, height: number, x?: number, y?: number} { + return appSettings.get('windowArrangements', {})[windowKey]; +} + +function saveWindowPosition( + windowKey: string, + position: {x: number, y: number, width: number, height: number}, +) { + const windowArrangements = appSettings.get('windowArrangements', {}); + windowArrangements[windowKey] = position; + appSettings.set('windowArrangements', windowArrangements); +} + +function setupWindowResizeListeners( + browserWindow: typeof BrowserWindow, + windowKey: string, +) { + const savePosition = () => { + if (!browserWindow.isDestroyed()) { + const [x, y] = browserWindow.getPosition(); + const [width, height] = browserWindow.getSize(); + saveWindowPosition(windowKey, {x, y, width, height}); + } + }; + browserWindow.on('moved', savePosition); + browserWindow.on('resized', savePosition); + browserWindow.on('closed', savePosition); +} + app.whenReady().then(() => { handleLaunchArgs(process.argv.slice(app.isPackaged ? 1 : 2)); configureAppMenu(); diff --git a/packages/debugger-shell/src/electron/SettingsStore.js b/packages/debugger-shell/src/electron/SettingsStore.js new file mode 100644 index 000000000000..ce684891ad31 --- /dev/null +++ b/packages/debugger-shell/src/electron/SettingsStore.js @@ -0,0 +1,130 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +// $FlowFixMe[unclear-type] We have no Flow types for the Electron API. +const {app} = require('electron') as any; +const fs = require('fs'); +const path = require('path'); + +type Options = $ReadOnly<{ + name?: string, + defaults?: Object, +}>; + +/** + * A data persistence layer for storing application settings, modelled after + * [`electron-store`](https://www.npmjs.com/package/electron-store). + * + * Values are saved in a `config.json` file in `app.getPath('userData')`. + * + * Compatibility: + * - Maintains API and file format compatibility with `electron-store@8.2.0`. + * - Supports the Electron main process only. + */ +class SettingsStore { + #defaultValues: Object = {}; + path: string; + + constructor(options: Options = {}) { + options = { + name: 'config', + ...options, + }; + this.#defaultValues = { + ...this.#defaultValues, + ...options.defaults, + }; + this.path = path.resolve( + app.getPath('userData'), + `${options.name ?? 'config'}.json`, + ); + } + + get(key: string, defaultValue?: any): any { + const store = this.store; + return store[key] !== undefined ? store[key] : defaultValue; + } + + set(key: string, value: any): void { + const {store} = this; + if (typeof key === 'object') { + const object = key; + for (const [k, v] of Object.entries(object)) { + store[k] = v; + } + } else { + store[key] = value; + } + this.store = store; + } + + has(key: string): boolean { + return key in this.store; + } + + reset(...keys: Array): void { + for (const key of keys) { + if (this.#defaultValues[key] != null) { + this.set(key, this.#defaultValues[key]); + } + } + } + + delete(key: string): void { + const {store} = this; + delete store[key]; + this.store = store; + } + + clear(): void { + this.store = {}; + for (const key of Object.keys(this.#defaultValues)) { + this.reset(key); + } + } + + get store(): {[string]: mixed} { + try { + const data = fs.readFileSync(this.path, 'utf8'); + const deserializedData = this._deserialize(data); + return { + ...((deserializedData: any): {[string]: mixed}), + }; + } catch (error) { + if (error?.code === 'ENOENT') { + this._ensureDirectory(); + return {}; + } + throw error; + } + } + + set store(value: mixed): void { + this._ensureDirectory(); + this._write(value); + } + + _deserialize = (value: string): mixed => JSON.parse(value); + _serialize = (value: mixed): string => + JSON.stringify(value, undefined, '\t') ?? ''; + + _ensureDirectory(): void { + fs.mkdirSync(path.dirname(this.path), {recursive: true}); + } + + _write(value: mixed): void { + const data = this._serialize(value); + + fs.writeFileSync(this.path, data, {mode: 0o666}); + } +} + +module.exports = SettingsStore;