diff --git a/src/main/app.js b/src/main/app.js index e5ef2af..09e439c 100644 --- a/src/main/app.js +++ b/src/main/app.js @@ -6,12 +6,14 @@ import { BrowserWindow, MessageChannelMain, app, + dialog, ipcMain, safeStorage, utilityProcess, } from 'electron/main' import { Intl, getSystemLocale } from './intl.js' +import { APP_IPC_EVENT_TO_PARAMS_PARSER } from './ipc.js' const log = debug('comapeo:main:app') @@ -226,6 +228,25 @@ function initMainWindow({ appMode, services }) { event.senderFrame?.postMessage('provide-comapeo-port', null, [port2]) }) + // Set up IPC specific to the main window + mainWindow.webContents.ipc.handle('files:select', async (_event, params) => { + const parsedParams = APP_IPC_EVENT_TO_PARAMS_PARSER['files:select'](params) + + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: parsedParams?.extensionFilters + ? [ + { + name: 'Custom file type', + extensions: parsedParams.extensionFilters, + }, + ] + : undefined, + }) + + return result.filePaths[0] + }) + APP_STATE.browserWindows.set(mainWindow, { type: 'main', }) diff --git a/src/main/ipc.js b/src/main/ipc.js new file mode 100644 index 0000000..6cbb4a0 --- /dev/null +++ b/src/main/ipc.js @@ -0,0 +1,29 @@ +import { + array, + object, + optional, + parse, + string, + undefined, + union, +} from 'valibot' + +const FilesSelectParamsSchema = union([ + object({ + extensionFilters: optional(array(string())), + }), + undefined(), +]) + +export const APP_IPC_EVENT_TO_PARAMS_PARSER = /** @type {const} */ ({ + /** + * @param {unknown} value + * + * @returns {import('valibot').InferOutput} + */ + 'files:select': (value) => { + return parse(FilesSelectParamsSchema, value) + }, +}) + +/** @typedef {keyof typeof APP_IPC_EVENT_TO_PARAMS_PARSER} AppIPCEvents */ diff --git a/src/preload/main-window.js b/src/preload/main-window.js index b282182..7f7a3a7 100644 --- a/src/preload/main-window.js +++ b/src/preload/main-window.js @@ -23,12 +23,27 @@ const runtimeApi = { // Locale async getLocale() { const locale = await ipcRenderer.invoke('locale:get') - if (typeof locale !== 'string') throw Error('Locale must be a string') + if (typeof locale !== 'string') { + throw new Error('Locale must be a string') + } return locale }, updateLocale(locale) { ipcRenderer.send('locale:update', locale) }, + + // Files + async selectFile(extensionFilters) { + const filePath = await ipcRenderer.invoke('files:select', { + extensionFilters, + }) + + if (!(typeof filePath === 'string' || typeof filePath === 'undefined')) { + throw new Error(`File path is unexpected type: ${typeof filePath}`) + } + + return filePath + }, } contextBridge.exposeInMainWorld('runtime', runtimeApi) diff --git a/src/preload/runtime.d.ts b/src/preload/runtime.d.ts index 64d25be..39a44fe 100644 --- a/src/preload/runtime.d.ts +++ b/src/preload/runtime.d.ts @@ -2,4 +2,5 @@ export type RuntimeApi = { init: () => void getLocale: () => Promise updateLocale: (locale: string) => void + selectFile: (extensionFilters?: Array) => Promise } diff --git a/src/renderer/src/hooks/mutations/file-system.ts b/src/renderer/src/hooks/mutations/file-system.ts new file mode 100644 index 0000000..c0591a5 --- /dev/null +++ b/src/renderer/src/hooks/mutations/file-system.ts @@ -0,0 +1,13 @@ +import { useMutation } from '@tanstack/react-query' + +/** + * If the resolved value is `undefined` that means the user did not select a + * file (i.e. cancelled the selection dialog) + */ +export function useSelectProjectConfigFile() { + return useMutation({ + mutationFn: async () => { + return window.runtime.selectFile(['comapeocat']) + }, + }) +}