diff --git a/src/main/index.ts b/src/main/index.ts index 06a4ac47..5775e8c4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,9 +13,10 @@ import { stat } from 'node:fs/promises'; import assert from 'node:assert'; import timers from 'node:timers/promises'; import { z } from 'zod'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import electronUnhandled from 'electron-unhandled'; import { fileTypeFromFile } from 'file-type/node'; +import type { Asyncify } from 'type-fest'; import logger from './logger.js'; import menu from './menu.js'; @@ -131,6 +132,7 @@ function createWindow() { nodeIntegration: true, // https://github.com/electron/electron/issues/5107 webSecurity: !isDev, + preload: fileURLToPath(new URL('../preload/index.cjs', import.meta.url)), }, backgroundColor: darkMode ? '#333' : '#fff', minWidth: 300, @@ -399,6 +401,23 @@ function sendOsNotification(options: NotificationConstructorOptions) { } const remoteApi = { + pathExists, + downloadMediaUrl, + fileTypeFromFile, + focusWindow, + quitApp, + setProgressBar, + sendOsNotification, +}; + +export type RemoteApi = typeof remoteApi; + +export type RemoteRpcApi = { + [K in keyof RemoteApi]: Asyncify; +}; + +// using @electron/remote +const remoteApiLegacy = { ffmpeg, i18n: i18nCommon, compatPlayer, @@ -408,29 +427,32 @@ const remoteApi = { isMac, platform, arch, - pathExists, - pathToFileURL, - downloadMediaUrl, - fileTypeFromFile, isDev, lossyMode, - focusWindow, - quitApp, + pathToFileURL, hasDisabledNetworking, - setProgressBar, - sendOsNotification, }; -export type RemoteApi = typeof remoteApi; +export type RemoteApiLegacy = typeof remoteApiLegacy; + // @ts-expect-error don't know how to type -app.addListener('remote-require', (event: { returnValue: RemoteApi }, _webContents: unknown, moduleName: string) => { +app.addListener('remote-require', (event: { returnValue: RemoteApiLegacy }, _webContents: unknown, moduleName: string) => { if (moduleName === './index.js') { // eslint-disable-next-line no-param-reassign - event.returnValue = remoteApi; + event.returnValue = remoteApiLegacy; } }); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +ipcMain.handle('__electron_rpc__', async (_event, method: keyof RemoteApi, args: any[]) => { + const fn = remoteApi[method]; + assert(fn, `Unknown API method: ${method}`); + // @ts-expect-error don't know how to type + return fn(...args); +}); + // cannot top level await because app.whenReady will hang forever // eslint-disable-next-line unicorn/prefer-top-level-await init(); diff --git a/src/preload/index.ts b/src/preload/index.ts index 07d67e5e..78568263 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,2 +1,20 @@ -// todo -console.log('preload'); +import type { RemoteRpcApi } from '../main/index.ts'; + +// eslint-disable-next-line import/no-extraneous-dependencies, @typescript-eslint/no-var-requires +const { ipcRenderer } = require('electron'); + +const apiProxy = new Proxy( + {} as RemoteRpcApi, + { + get(_target, method: keyof RemoteRpcApi) { + return (...args: unknown[]) => ( + ipcRenderer.invoke('__electron_rpc__', method, args) + ); + }, + }, +); + +// todo use contextBridge instead once we get rid of @electron/remote +// @ts-expect-error untyped +window.electron = apiProxy; +// contextBridge.exposeInMainWorld('electron', apiProxy); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 41dee390..41f2e55f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -111,12 +111,13 @@ import useErrorHandling from './hooks/useErrorHandling'; import GenericDialog, { useDialog } from './components/GenericDialog'; import useHtml5ify from './hooks/useHtml5ify'; import WhatsNew from './components/WhatsNew'; +import mainApi from './mainApi.js'; const electron = window.require('electron'); const { lstat } = window.require('fs/promises'); const { parse: parsePath, join: pathJoin, basename, dirname } = window.require('path'); -const { focusWindow, hasDisabledNetworking, quitApp, pathToFileURL, setProgressBar, sendOsNotification, lossyMode, pathExists } = window.require('@electron/remote').require('./index.js'); +const { hasDisabledNetworking, pathToFileURL, lossyMode } = window.require('@electron/remote').require('./index.js'); const hevcPlaybackSupportedPromise = doesPlayerSupportHevcPlayback(); @@ -200,7 +201,9 @@ function App() { useEffect(() => setDocumentTitle({ filePath, working: working?.text, progress }), [progress, filePath, working?.text]); - useEffect(() => setProgressBar(progress ?? -1), [progress]); + useEffect(() => { + mainApi.setProgressBar(progress ?? -1); + }, [progress]); useEffect(() => { ffmpegSetCustomFfPath(customFfPath); @@ -248,7 +251,7 @@ function App() { const showOsNotification = useCallback((text: string) => { if (hideOsNotifications == null) { - sendOsNotification({ title: text }); + mainApi.sendOsNotification({ title: text }); } }, [hideOsNotifications]); @@ -1339,7 +1342,7 @@ function App() { const loadMedia = useCallback(async ({ filePath: fp, projectPath }: { filePath: string, projectPath?: string | undefined }) => { async function tryOpenProjectPath(path: string) { - if (!(await pathExists(path))) return false; + if (!(await mainApi.pathExists(path))) return false; await loadEdlFile({ path, type: 'llc' }); return true; } @@ -1355,7 +1358,7 @@ function App() { const sameDirEdlFilePath = getEdlFilePath(fp); // MAS only allows fs.access (pathExists) if we don't have access to input dir yet, so check first if the file exists, // so we don't need to annoy the user by asking for permission if the project file doesn't exist - if (await pathExists(sameDirEdlFilePath)) { + if (await mainApi.pathExists(sameDirEdlFilePath)) { // Ok, the file exists. now we have to ask the user, because we need to read that file await ensureAccessToSourceDir(fp); // Ok, we got access from the user (or already have access), now read the project file @@ -1523,7 +1526,7 @@ function App() { const mediaFilePath = pathJoin(dirname(path), mediaFileName); // Note: MAS only allows fs.stat (pathExists) if we don't have access to input dir yet - if (!(await pathExists(mediaFilePath))) { + if (!(await mainApi.pathExists(mediaFilePath))) { errorToast(i18n.t('The media file referenced by the project file you tried to open does not exist in the same directory as the project file: {{mediaFileName}}', { mediaFileName })); return; } @@ -2063,7 +2066,7 @@ function App() { toggleMuted, copySegmentsToClipboard, reloadFile: () => setCacheBuster((v) => v + 1), - quit: () => quitApp(), + quit: () => mainApi.quitApp(), closeCurrentFile: () => { closeFileWithConfirm(); }, exportYouTube, showStreamsSelector: handleShowStreamsSelectorClick, @@ -2314,7 +2317,7 @@ function App() { if (!ev.dataTransfer) return; await withErrorHandling(async () => { const filePaths = [...ev.dataTransfer.files].map((f) => electron.webUtils.getPathForFile(f)); - focusWindow(); + await mainApi.focusWindow(); batchLoadPaths(filePaths, true); }); }, [batchLoadPaths, withErrorHandling]); @@ -2325,7 +2328,7 @@ function App() { await withErrorHandling(async () => { const filePaths = [...ev.dataTransfer.files].map((f) => electron.webUtils.getPathForFile(f)); if (filePaths.length !== 1) return; - focusWindow(); + await mainApi.focusWindow(); addStreamSourceFile(filePaths[0]!); }); }, [addStreamSourceFile, withErrorHandling]); @@ -2335,7 +2338,7 @@ function App() { ev.preventDefault(); if (!ev.dataTransfer) return; const filePaths = [...ev.dataTransfer.files].map((f) => electron.webUtils.getPathForFile(f)); - focusWindow(); + await mainApi.focusWindow(); userOpenFiles(filePaths); } const element = videoContainerRef.current; diff --git a/src/renderer/src/dialogs/index.tsx b/src/renderer/src/dialogs/index.tsx index 1edb0558..44d62ae3 100644 --- a/src/renderer/src/dialogs/index.tsx +++ b/src/renderer/src/dialogs/index.tsx @@ -15,11 +15,11 @@ import type { FindKeyframeMode } from '../ffmpeg'; import { dangerColor, primaryColor, warningColor } from '../colors'; import getSwal from '../swal'; import isDev from '../isDev'; +import mainApi from '../mainApi'; const remote = window.require('@electron/remote'); const { dialog } = remote; -const { downloadMediaUrl } = remote.require('./index.js'); // https://github.com/mifi/lossless-cut/issues/1495 export const showOpenDialog = async ({ @@ -561,7 +561,7 @@ export async function promptDownloadMediaUrl(outPath: string) { if (!value) return false; - await downloadMediaUrl(value, outPath); + await mainApi.downloadMediaUrl(value, outPath); return true; } diff --git a/src/renderer/src/ffmpeg.ts b/src/renderer/src/ffmpeg.ts index afce7a6c..7282cf4a 100644 --- a/src/renderer/src/ffmpeg.ts +++ b/src/renderer/src/ffmpeg.ts @@ -12,8 +12,9 @@ import { isDurationValid } from './segments'; import type { FFprobeChapter, FFprobeFormat, FFprobeProbeResult, FFprobeStream } from '../../common/ffprobe'; import { parseSrt, parseSrtToSegments } from './edlFormats'; import { UnsupportedFileError, UserFacingError } from '../errors'; +import mainApi from './mainApi'; -const { ffmpeg, fileTypeFromFile } = window.require('@electron/remote').require('./index.js'); +const { ffmpeg } = window.require('@electron/remote').require('./index.js'); const { renderWaveformPng, mapTimesToSegments, detectSceneChanges, captureFrames, captureFrameToFile, captureFrameToClipboard, getFfCommandLine, runFfmpegConcat, runFfmpegWithProgress, getDuration, abortFfmpegs, runFfmpeg, runFfprobe, getFfmpegPath, setCustomFfPath } = ffmpeg; @@ -268,7 +269,7 @@ async function determineSourceFileFormat(ffprobeFormatsStr: string | undefined, // We need to test mp3 first because ffprobe seems to report the wrong format sometimes https://github.com/mifi/lossless-cut/issues/2129 if (firstFfprobeFormat === 'mp3') { // file-type detects it correctly - const fileTypeResponse = await fileTypeFromFile(filePath); + const fileTypeResponse = await mainApi.fileTypeFromFile(filePath); if (fileTypeResponse?.mime === 'audio/mpeg') { return 'mp2'; } @@ -287,7 +288,7 @@ async function determineSourceFileFormat(ffprobeFormatsStr: string | undefined, return firstFfprobeFormat; } - const fileTypeResponse = await fileTypeFromFile(filePath); + const fileTypeResponse = await mainApi.fileTypeFromFile(filePath); if (fileTypeResponse == null) { console.warn('file-type failed to detect format, defaulting to first FFprobe detected format', ffprobeFormats); return firstFfprobeFormat; diff --git a/src/renderer/src/hooks/useDirectoryAccess.ts b/src/renderer/src/hooks/useDirectoryAccess.ts index 77057a84..ba69f792 100644 --- a/src/renderer/src/hooks/useDirectoryAccess.ts +++ b/src/renderer/src/hooks/useDirectoryAccess.ts @@ -2,13 +2,17 @@ import { useCallback } from 'react'; import i18n from 'i18next'; import invariant from 'tiny-invariant'; -import { getOutDir, getFileDir, checkDirWriteAccess, dirExists, isMasBuild } from '../util'; +import { getOutDir, getFileDir, checkDirWriteAccess, isMasBuild } from '../util'; import { askForOutDir, askForInputDir } from '../dialogs'; import { errorToast } from '../swal'; import { DirectoryAccessDeclinedError } from '../../errors'; +import mainApi from '../mainApi'; // import isDev from '../isDev'; +const { lstat } = window.require('fs/promises'); + + // MacOS App Store sandbox doesn't allow reading/writing anywhere, // except those exact file paths that have been explicitly drag-dropped into LosslessCut or opened using the opener dialog // Therefore we set the flag com.apple.security.files.user-selected.read-write @@ -60,7 +64,7 @@ export default function useDirectoryAccess({ setCustomOutDir }: { setCustomOutDi if (newCustomOutDir) { // Reset if working directory doesn't exist anymore - const customOutDirExists = await dirExists(newCustomOutDir); + const customOutDirExists = (await mainApi.pathExists(newCustomOutDir)) && (await lstat(newCustomOutDir)).isDirectory(); if (!customOutDirExists) { setCustomOutDir(undefined); newCustomOutDir = undefined; diff --git a/src/renderer/src/hooks/useFfmpegOperations.ts b/src/renderer/src/hooks/useFfmpegOperations.ts index 093bcf52..a9ef2203 100644 --- a/src/renderer/src/hooks/useFfmpegOperations.ts +++ b/src/renderer/src/hooks/useFfmpegOperations.ts @@ -15,10 +15,10 @@ import type { AvoidNegativeTs, Html5ifyMode, PreserveMetadata } from '../../../c import type { AllFilesMeta, Chapter, CopyfileStreams, CustomTagsByFile, LiteFFprobeStream, ParamsByStreamId, SegmentToExport } from '../types'; import type { LossyMode } from '../../../main'; import { UserFacingError } from '../../errors'; +import mainApi from '../mainApi'; const { join, resolve, dirname } = window.require('path'); const { writeFile, mkdir, access, constants: { W_OK } } = window.require('fs/promises'); -const { pathExists } = window.require('@electron/remote').require('./index.js'); export class OutputNotWritableError extends Error { @@ -94,7 +94,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea appendFfmpegCommandLog: (args: string[]) => void, }) { const shouldSkipExistingFile = useCallback(async (path: string) => { - const fileExists = await pathExists(path); + const fileExists = await mainApi.pathExists(path); // If output file exists, check that it is writable, so we can inform user if it's not (or else ffmpeg will fail with "Permission denied") // this seems to sometimes happen on Windows, not sure why. @@ -917,7 +917,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea let streamArgs: string[] = []; const outPaths = await pMap(outStreams, async ({ index, codec, type, format: { format, ext } }) => { const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `stream-${index}-${type}-${codec}.${ext}` }); - if (!enableOverwriteOutput && await pathExists(outPath)) throw new RefuseOverwriteError(); + if (!enableOverwriteOutput && await mainApi.pathExists(outPath)) throw new RefuseOverwriteError(); streamArgs = [ ...streamArgs, @@ -953,7 +953,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea const ext = codec || 'bin'; const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `stream-${index}-${type}-${codec}.${ext}` }); invariant(outPath != null); - if (!enableOverwriteOutput && await pathExists(outPath)) throw new RefuseOverwriteError(); + if (!enableOverwriteOutput && await mainApi.pathExists(outPath)) throw new RefuseOverwriteError(); streamArgs = [ ...streamArgs, diff --git a/src/renderer/src/index.tsx b/src/renderer/src/index.tsx index eb6cddc7..2d32d470 100644 --- a/src/renderer/src/index.tsx +++ b/src/renderer/src/index.tsx @@ -27,7 +27,7 @@ import App from './App'; import ErrorBoundary from './ErrorBoundary'; import './i18n'; -import { RemoteApi } from '../../main'; +import { RemoteApiLegacy, RemoteRpcApi } from '../../main'; import './main.css'; import './swal2.scss'; @@ -35,7 +35,7 @@ import './swal2.scss'; type TypedRemote = Omit & { require: (module: T) => ( - T extends './index.js' ? RemoteApi : + T extends './index.js' ? RemoteApiLegacy : // eslint-disable-next-line @typescript-eslint/no-explicit-any any ); @@ -55,6 +55,7 @@ declare global { T extends 'cue-parser' ? typeof cueParser : never ); + electron: RemoteRpcApi; } interface Navigator { keyboard: { diff --git a/src/renderer/src/mainApi.ts b/src/renderer/src/mainApi.ts new file mode 100644 index 00000000..10d58f5a --- /dev/null +++ b/src/renderer/src/mainApi.ts @@ -0,0 +1,3 @@ +const mainApi = window.electron; + +export default mainApi; diff --git a/src/renderer/src/util.ts b/src/renderer/src/util.ts index c00df0d6..e635732b 100644 --- a/src/renderer/src/util.ts +++ b/src/renderer/src/util.ts @@ -18,7 +18,7 @@ const { dirname, parse: parsePath, join, extname, isAbsolute, resolve, basename const { stat, lstat, readdir, utimes, unlink, open, access, constants: { R_OK, W_OK } } = window.require('fs/promises'); const { ipcRenderer } = window.require('electron'); const remote = window.require('@electron/remote'); -const { isWindows, isMac, pathExists } = remote.require('./index.js'); +const { isWindows, isMac } = remote.require('./index.js'); const appVersion = remote.app.getVersion(); const appPath = remote.app.getAppPath(); @@ -104,10 +104,6 @@ export async function getPathReadAccessError(pathIn: string) { } } -export async function dirExists(dirPath: string) { - return (await pathExists(dirPath)) && (await lstat(dirPath)).isDirectory(); -} - // export const testFailFsOperation = isDev; export const testFailFsOperation = false;