617 changes: 0 additions & 617 deletions src/components/KeyboardShortcuts.jsx

This file was deleted.

118 changes: 0 additions & 118 deletions src/components/OutSegTemplateEditor.jsx

This file was deleted.

44 changes: 0 additions & 44 deletions src/components/OutputFormatSelect.jsx

This file was deleted.

25 changes: 0 additions & 25 deletions src/components/SegmentCutpointButton.jsx

This file was deleted.

10 changes: 0 additions & 10 deletions src/components/Select.jsx

This file was deleted.

18 changes: 0 additions & 18 deletions src/components/Select.module.css

This file was deleted.

27 changes: 0 additions & 27 deletions src/components/Sheet.jsx

This file was deleted.

55 changes: 0 additions & 55 deletions src/components/SubtitleControl.jsx

This file was deleted.

12 changes: 0 additions & 12 deletions src/components/Switch.jsx

This file was deleted.

30 changes: 0 additions & 30 deletions src/components/ValueTuner.jsx

This file was deleted.

45 changes: 0 additions & 45 deletions src/components/Working.jsx

This file was deleted.

3 changes: 0 additions & 3 deletions src/contexts/UserSettingsContext.js

This file was deleted.

67 changes: 0 additions & 67 deletions src/dialogs/html5ify.jsx

This file was deleted.

80 changes: 0 additions & 80 deletions src/dialogs/parameters.jsx

This file was deleted.

246 changes: 0 additions & 246 deletions src/edlFormats.js

This file was deleted.

209 changes: 0 additions & 209 deletions src/edlFormats.test.js

This file was deleted.

135 changes: 0 additions & 135 deletions src/edlStore.js

This file was deleted.

1,113 changes: 0 additions & 1,113 deletions src/ffmpeg.js

This file was deleted.

527 changes: 0 additions & 527 deletions src/hooks/useFfmpegOperations.js

This file was deleted.

35 changes: 0 additions & 35 deletions src/hooks/useKeyboard.js

This file was deleted.

5 changes: 0 additions & 5 deletions src/hooks/useUserSettings.js

This file was deleted.

61 changes: 0 additions & 61 deletions src/hooks/useWaveform.js

This file was deleted.

42 changes: 0 additions & 42 deletions src/index.jsx

This file was deleted.

106 changes: 106 additions & 0 deletions src/main/compatPlayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from 'node:assert';

import logger from './logger.js';
import { createMediaSourceProcess, readOneJpegFrame as readOneJpegFrameRaw } from './ffmpeg.js';


export function createMediaSourceStream({ path, videoStreamIndex, audioStreamIndex, seekTo, size, fps }: {
path: string, videoStreamIndex?: number | undefined, audioStreamIndex?: number | undefined, seekTo: number, size?: number | undefined, fps?: number | undefined,
}) {
const abortController = new AbortController();
logger.info('Starting preview process', { videoStreamIndex, audioStreamIndex, seekTo });
const process = createMediaSourceProcess({ path, videoStreamIndex, audioStreamIndex, seekTo, size, fps });

// eslint-disable-next-line unicorn/prefer-add-event-listener
abortController.signal.onabort = () => {
logger.info('Aborting preview process', { videoStreamIndex, audioStreamIndex, seekTo });
process.kill('SIGKILL');
};

const { stdout } = process;
assert(stdout != null);

stdout.pause();

const readChunk = async () => new Promise((resolve, reject) => {
let cleanup: () => void;

const onClose = () => {
cleanup();
resolve(null);
};
const onData = (chunk: Buffer) => {
stdout.pause();
cleanup();
resolve(chunk);
};
const onError = (err: Error) => {
cleanup();
reject(err);
};
cleanup = () => {
stdout.off('data', onData);
stdout.off('error', onError);
stdout.off('close', onClose);
};

stdout.once('data', onData);
stdout.once('error', onError);
stdout.once('close', onClose);

stdout.resume();
});

function abort() {
abortController.abort();
}

let stderr = Buffer.alloc(0);
process.stderr?.on('data', (chunk) => {
stderr = Buffer.concat([stderr, chunk]);
});

(async () => {
try {
await process;
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return;
}

// @ts-expect-error todo
if (!(err.killed)) {
// @ts-expect-error todo
console.warn(err.message);
console.warn(stderr.toString('utf8'));
}
}
})();

return { abort, readChunk };
}

export function readOneJpegFrame({ path, seekTo, videoStreamIndex }: { path: string, seekTo: number, videoStreamIndex: number }) {
const abortController = new AbortController();
const process = readOneJpegFrameRaw({ path, seekTo, videoStreamIndex });

// eslint-disable-next-line unicorn/prefer-add-event-listener
abortController.signal.onabort = () => process.kill('SIGKILL');

function abort() {
abortController.abort();
}

const promise = (async () => {
try {
const { stdout } = await process;
return stdout;
} catch (err) {
// @ts-expect-error todo
logger.error('renderOneJpegFrame', err.shortMessage);
throw new Error('Failed to render JPEG frame');
}
})();

return { promise, abort };
}
109 changes: 72 additions & 37 deletions public/configStore.js → src/main/configStore.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
const Store = require('electron-store');
const electron = require('electron');
const os = require('os');
const { join, dirname } = require('path');
const { pathExists } = require('fs-extra');
import Store from 'electron-store';
// eslint-disable-next-line import/no-extraneous-dependencies
import electron from 'electron';
import { join, dirname } from 'node:path';
import { pathExists } from 'fs-extra';

const logger = require('./logger');
import { KeyBinding, Config } from '../../types.js';
import logger from './logger.js';
import { isWindows } from './util.js';

const { app } = electron;


const defaultKeyBindings = [
const defaultKeyBindings: KeyBinding[] = [
{ keys: 'plus', action: 'addSegment' },
{ keys: 'space', action: 'togglePlayResetSpeed' },
{ keys: 'k', action: 'togglePlayNoResetSpeed' },
Expand All @@ -30,12 +32,14 @@ const defaultKeyBindings = [
{ keys: 'g', action: 'goToTimecode' },

{ keys: 'left', action: 'seekBackwards' },
{ keys: 'ctrl+shift+left', action: 'seekBackwards2' },
{ keys: 'ctrl+left', action: 'seekBackwardsPercent' },
{ keys: 'command+left', action: 'seekBackwardsPercent' },
{ keys: 'alt+left', action: 'seekBackwardsKeyframe' },
{ keys: 'shift+left', action: 'jumpCutStart' },

{ keys: 'right', action: 'seekForwards' },
{ keys: 'ctrl+shift+right', action: 'seekForwards2' },
{ keys: 'ctrl+right', action: 'seekForwardsPercent' },
{ keys: 'command+right', action: 'seekForwardsPercent' },
{ keys: 'alt+right', action: 'seekForwardsKeyframe' },
Expand All @@ -44,15 +48,19 @@ const defaultKeyBindings = [
{ keys: 'ctrl+home', action: 'jumpTimelineStart' },
{ keys: 'ctrl+end', action: 'jumpTimelineEnd' },

{ keys: 'pageup', action: 'jumpFirstSegment' },
{ keys: 'up', action: 'jumpPrevSegment' },
{ keys: 'ctrl+up', action: 'timelineZoomIn' },
{ keys: 'command+up', action: 'timelineZoomIn' },
{ keys: 'shift+up', action: 'batchPreviousFile' },
{ keys: 'ctrl+shift+up', action: 'batchOpenPreviousFile' },

{ keys: 'pagedown', action: 'jumpLastSegment' },
{ keys: 'down', action: 'jumpNextSegment' },
{ keys: 'ctrl+down', action: 'timelineZoomOut' },
{ keys: 'command+down', action: 'timelineZoomOut' },
{ keys: 'shift+down', action: 'batchNextFile' },
{ keys: 'ctrl+shift+down', action: 'batchOpenNextFile' },

{ keys: 'shift+enter', action: 'batchOpenSelectedFile' },

Expand All @@ -62,6 +70,11 @@ const defaultKeyBindings = [
{ keys: 'ctrl+shift+z', action: 'redo' },
{ keys: 'command+shift+z', action: 'redo' },

{ keys: 'ctrl+c', action: 'copySegmentsToClipboard' },
{ keys: 'command+c', action: 'copySegmentsToClipboard' },

{ keys: 'f', action: 'toggleFullscreenVideo' },

{ keys: 'enter', action: 'labelCurrentSegment' },

{ keys: 'e', action: 'export' },
Expand All @@ -70,9 +83,10 @@ const defaultKeyBindings = [

{ keys: 'alt+up', action: 'increaseVolume' },
{ keys: 'alt+down', action: 'decreaseVolume' },
{ keys: 'm', action: 'toggleMuted' },
];

const defaults = {
const defaults: Config = {
captureFormat: 'jpeg',
customOutDir: undefined,
keyframeCut: true,
Expand Down Expand Up @@ -103,7 +117,10 @@ const defaults = {
outSegTemplate: undefined,
keyboardSeekAccFactor: 1.03,
keyboardNormalSeekSpeed: 1,
enableTransferTimestamps: true,
keyboardSeekSpeed2: 10,
keyboardSeekSpeed3: 60,
treatInputFileModifiedTimeAsStart: true,
treatOutputFileModifiedTimeAsStart: true,
outFormatLocked: undefined,
safeOutputFileName: true,
windowBounds: undefined,
Expand All @@ -119,67 +136,85 @@ const defaults = {
enableNativeHevc: true,
enableUpdateCheck: true,
cleanupChoices: {
trashTmpFiles: true, askForCleanup: true,
trashTmpFiles: true, askForCleanup: true, closeFile: true, cleanupAfterExport: false,
},
allowMultipleInstances: false,
darkMode: true,
preferStrongColors: false,
outputFileNameMinZeroPadding: 1,
cutFromAdjustmentFrames: 0,
invertTimelineScroll: undefined,
};

// look for a config.json file next to the executable
// For portable app: https://github.com/mifi/lossless-cut/issues/645
async function getCustomStoragePath() {
async function lookForCustomStoragePath() {
try {
const isWindows = os.platform() === 'win32';
if (!isWindows || process.windowsStore) return undefined;

// https://github.com/mifi/lossless-cut/issues/645#issuecomment-1001363314
// https://stackoverflow.com/questions/46307797/how-to-get-the-original-path-of-a-portable-electron-app
// https://github.com/electron-userland/electron-builder/blob/master/docs/configuration/nsis.md
const customStorageDir = process.env.PORTABLE_EXECUTABLE_DIR || dirname(app.getPath('exe'));
if (!isWindows || process.windowsStore) return undefined;
const customStorageDir = process.env['PORTABLE_EXECUTABLE_DIR'] || dirname(app.getPath('exe'));
const customConfigPath = join(customStorageDir, 'config.json');
if (await pathExists(customConfigPath)) return customStorageDir;

return undefined;
} catch (err) {
logger.error('Failed to get custom storage path', err);
return undefined;
}
}

let store;
let store: Store;

async function init() {
const customStoragePath = await getCustomStoragePath();
if (customStoragePath) logger.info('customStoragePath', customStoragePath);
export function get<T extends keyof Config>(key: T): Config[T] {
return store.get(key);
}

export function set<T extends keyof Config>(key: T, val: Config[T]) {
if (val === undefined) store.delete(key);
else store.set(key, val);
}

export function reset<T extends keyof Config>(key: T) {
set(key, defaults[key]);
}

async function tryCreateStore({ customStoragePath }: { customStoragePath: string | undefined }) {
for (let i = 0; i < 5; i += 1) {
try {
store = new Store({ defaults, cwd: customStoragePath });
store = new Store({
defaults,
...(customStoragePath != null ? { cwd: customStoragePath } : {}),
});
return;
} catch (err) {
// eslint-disable-next-line no-await-in-loop
await new Promise(r => setTimeout(r, 2000));
await new Promise((r) => setTimeout(r, 2000));
logger.error('Failed to create config store, retrying', err);
}
}

throw new Error('Timed out while creating config store');
}

function get(key) {
return store.get(key);
}
export async function init({ customConfigDir }: { customConfigDir: string | undefined }) {
const customStoragePath = customConfigDir ?? await lookForCustomStoragePath();
if (customStoragePath) logger.info('customStoragePath', customStoragePath);

function set(key, val) {
if (val === undefined) store.delete(key);
else store.set(key, val);
}
await tryCreateStore({ customStoragePath });

function reset(key) {
set(key, defaults[key]);
}
// migrate old configs:
const enableTransferTimestamps = store.get('enableTransferTimestamps'); // todo remove after a while
if (enableTransferTimestamps != null) {
logger.info('Migrating enableTransferTimestamps');
store.delete('enableTransferTimestamps');
set('treatOutputFileModifiedTimeAsStart', enableTransferTimestamps ? true : undefined);
}

module.exports = {
init,
get,
set,
reset,
};
const cleanupChoices = store.get('cleanupChoices'); // todo remove after a while
if (cleanupChoices != null && cleanupChoices.closeFile == null) {
logger.info('Migrating cleanupChoices.closeFile');
set('cleanupChoices', { ...cleanupChoices, closeFile: true });
}
}
4 changes: 4 additions & 0 deletions src/main/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const homepage = 'https://mifi.no/losslesscut/';
export const githubLink = 'https://github.com/mifi/lossless-cut/';
export const getReleaseUrl = (version: string) => `https://github.com/mifi/lossless-cut/releases/tag/v${version}`;
export const licensesPage = 'https://losslesscut.mifi.no/licenses.txt';
31 changes: 31 additions & 0 deletions src/main/contextMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { BrowserWindow, Menu } from 'electron';

// https://github.com/electron/electron/issues/4068#issuecomment-274159726
export default (window: BrowserWindow) => {
const selectionMenu = Menu.buildFromTemplate([
{ role: 'copy' },
{ type: 'separator' },
{ role: 'selectAll' },
]);

const inputMenu = Menu.buildFromTemplate([
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ type: 'separator' },
{ role: 'selectAll' },
]);

window.webContents.on('context-menu', (_e, props) => {
const { selectionText, isEditable } = props;
if (isEditable) {
inputMenu.popup({ window });
} else if (selectionText && selectionText.trim() !== '') {
selectionMenu.popup({ window });
}
});
};
659 changes: 659 additions & 0 deletions src/main/ffmpeg.ts

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions src/main/httpServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import express from 'express';
import morgan from 'morgan';
import http from 'node:http';
import asyncHandler from 'express-async-handler';
import assert from 'node:assert';

import { homepage } from './constants.js';
import logger from './logger.js';


export default ({ port, onKeyboardAction }: {
port: number, onKeyboardAction: (a: string) => Promise<void>,
}) => {
const app = express();

// https://expressjs.com/en/resources/middleware/morgan.html
const morganFormat = ':remote-addr :method :url HTTP/:http-version :status - :response-time ms';
// https://stackoverflow.com/questions/27906551/node-js-logging-use-morgan-and-winston
app.use(morgan(morganFormat, {
stream: { write: (message) => logger.info(message.trim()) },
}));

const apiRouter = express.Router();

app.get('/', (_req, res) => res.send(`See ${homepage}`));

app.use('/api', apiRouter);

apiRouter.post('/shortcuts/:action', express.json(), asyncHandler(async (req, res) => {
// eslint-disable-next-line prefer-destructuring
const action = req.params['action'];
assert(action != null);
await onKeyboardAction(action);
res.end();
}));

const server = http.createServer(app);

server.on('error', (err) => logger.error('http server error', err));

const startHttpServer = async () => new Promise((resolve, reject) => {
// force ipv4
const host = '127.0.0.1';
server.listen(port, host, () => {
logger.info('HTTP API listening on', `http://${host}:${port}/`);
// @ts-expect-error tod
resolve();
});

server.once('error', reject);
});

return {
startHttpServer,
};
};
10 changes: 4 additions & 6 deletions public/i18n.js → src/main/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
const i18n = require('i18next');
const Backend = require('i18next-fs-backend');
import i18n from 'i18next';
import Backend from 'i18next-fs-backend';

const { commonI18nOptions, loadPath, addPath } = require('./i18n-common');
import { commonI18nOptions, loadPath, addPath } from './i18nCommon.js';

// See also renderer

// https://github.com/i18next/i18next/issues/869
i18n
export default i18n
.use(Backend)
// detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector
Expand All @@ -23,5 +23,3 @@ i18n
addPath,
},
});

module.exports = i18n;
38 changes: 15 additions & 23 deletions public/i18n-common.js → src/main/i18nCommon.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
// intentionally disabled because I don't know the quality of the languages, so better to default to english
// const LanguageDetector = window.require('i18next-electron-language-detector');
const isDev = require('electron-is-dev');
const { app } = require('electron');
const { join } = require('path');
// eslint-disable-next-line import/no-extraneous-dependencies
import { app } from 'electron';
import { join } from 'node:path';
import { InitOptions } from 'i18next';

const { frontendBuildDir } = require('./util');

let customLocalesPath;
function setCustomLocalesPath(p) {
let customLocalesPath: string | undefined;
export function setCustomLocalesPath(p: string) {
customLocalesPath = p;
}

function getLangPath(subPath) {
function getLangPath(subPath: string) {
if (customLocalesPath != null) return join(customLocalesPath, subPath);
if (isDev) return join('public', subPath);
return join(app.getAppPath(), frontendBuildDir, subPath);
if (app.isPackaged) return join(process.resourcesPath, 'locales', subPath);
return join('locales', subPath);
}

// Weblate hardcodes different lang codes than electron
// https://www.electronjs.org/docs/api/app#appgetlocale
// https://source.chromium.org/chromium/chromium/src/+/master:ui/base/l10n/l10n_util.cc
const mapLang = (lng) => ({
const mapLang = (lng: string) => ({
nb: 'nb_NO',
no: 'nb_NO',
zh: 'zh_Hans',
Expand All @@ -37,29 +37,21 @@ const mapLang = (lng) => ({
'ru-RU': 'ru',
}[lng] || lng);

const fallbackLng = 'en';
export const fallbackLng = 'en';

const commonI18nOptions = {
export const commonI18nOptions: InitOptions = {
fallbackLng,
// debug: isDev,
// saveMissing: isDev,
// updateMissing: isDev,
// saveMissingTo: 'all',

// Keep in sync between i18next-parser.config.js and i18n-common.js:
// Keep in sync between i18next-parser.config.js and i18nCommon.js:
// TODO improve keys?
// Maybe do something like this: https://stackoverflow.com/a/19405314/6519037
keySeparator: false,
nsSeparator: false,
};

const loadPath = (lng, ns) => getLangPath(`locales/${mapLang(lng)}/${ns}.json`);
const addPath = (lng, ns) => getLangPath(`locales/${mapLang(lng)}/${ns}.missing.json`);

module.exports = {
fallbackLng,
loadPath,
addPath,
commonI18nOptions,
setCustomLocalesPath,
};
export const loadPath = (lng: string, ns: string) => getLangPath(`${mapLang(lng)}/${ns}.json`);
export const addPath = (lng: string, ns: string) => getLangPath(`${mapLang(lng)}/${ns}.missing.json`);
202 changes: 153 additions & 49 deletions public/electron.js → src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,132 @@
/// <reference types="electron-vite/node" />
process.traceDeprecation = true;
process.traceProcessWarnings = true;

const electron = require('electron');
const isDev = require('electron-is-dev');
const unhandled = require('electron-unhandled');
const i18n = require('i18next');
const debounce = require('lodash/debounce');
const yargsParser = require('yargs-parser');
const JSON5 = require('json5');
const remote = require('@electron/remote/main');
const { stat } = require('fs/promises');
/* eslint-disable import/first */
// eslint-disable-next-line import/no-extraneous-dependencies
import electron, { AboutPanelOptionsOptions, BrowserWindow, BrowserWindowConstructorOptions, nativeTheme, shell, app, ipcMain } from 'electron';
import unhandled from 'electron-unhandled';
import i18n from 'i18next';
import debounce from 'lodash/debounce';
import yargsParser from 'yargs-parser';
import JSON5 from 'json5';
import remote from '@electron/remote/main';
import { stat } from 'node:fs/promises';
import assert from 'node:assert';

const logger = require('./logger');
const menu = require('./menu');
const configStore = require('./configStore');
const { frontendBuildDir } = require('./util');
import logger from './logger.js';
import menu from './menu.js';
import * as configStore from './configStore.js';
import { isLinux } from './util.js';
import attachContextMenu from './contextMenu.js';
import HttpServer from './httpServer.js';
import isDev from './isDev.js';

const { checkNewVersion } = require('./update-checker');
import { checkNewVersion } from './updateChecker.js';

const i18nCommon = require('./i18n-common');
import * as i18nCommon from './i18nCommon.js';

require('./i18n');
import './i18n.js';
import { ApiKeyboardActionRequest } from '../../types.js';

const { app, ipcMain, shell, BrowserWindow, nativeTheme } = electron;
export * as ffmpeg from './ffmpeg.js';

export * as i18n from './i18nCommon.js';

export * as compatPlayer from './compatPlayer.js';

export * as configStore from './configStore.js';

export { isLinux, isWindows, isMac, platform } from './util.js';

export { pathToFileURL } from 'node:url';


// https://www.i18next.com/overview/typescript#argument-of-type-defaulttfuncreturn-is-not-assignable-to-parameter-of-type-xyz
// todo This should not be necessary anymore since v23.0.0
declare module 'i18next' {
interface CustomTypeOptions {
returnNull: false;
}
}

// eslint-disable-next-line unicorn/prefer-export-from
export { isDev };

// https://chromestatus.com/feature/5748496434987008
// https://peter.sh/experiments/chromium-command-line-switches/
// https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/platform/runtime_enabled_features.json5
app.commandLine.appendSwitch('enable-blink-features', 'AudioVideoTracks');

remote.initialize();

unhandled({
showDialog: true,
});

app.name = 'LosslessCut';
const appName = 'LosslessCut';
const copyrightYear = 2024;

const appVersion = app.getVersion();

app.name = appName;

const isStoreBuild = process.windowsStore || process.mas;

const showVersion = !isStoreBuild;

const aboutPanelOptions: AboutPanelOptionsOptions = {
applicationName: appName,
copyright: `Copyright © ${copyrightYear} Mikael Finstad ❤️ 🇳🇴`,
version: '', // not very useful (MacOS only, and same as applicationVersion)
};

// https://github.com/electron/electron/issues/18918
// https://github.com/mifi/lossless-cut/issues/1537
if (isLinux) {
aboutPanelOptions.version = appVersion;
}
if (!showVersion) {
// https://github.com/mifi/lossless-cut/issues/1882
aboutPanelOptions.applicationVersion = `${process.windowsStore ? 'Microsoft Store' : 'App Store'} edition, based on GitHub v${appVersion}`;
}

// https://www.electronjs.org/docs/latest/api/app#appsetaboutpaneloptionsoptions
app.setAboutPanelOptions(aboutPanelOptions);

let filesToOpen = [];
let filesToOpen: string[] = [];

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let mainWindow;
let mainWindow: BrowserWindow | null;

let askBeforeClose = false;
let rendererReady = false;
let newVersion;
let disableNetworking;
let newVersion: string | undefined;
let disableNetworking: boolean;

const openFiles = (paths) => mainWindow.webContents.send('openFiles', paths);
const openFiles = (paths: string[]) => mainWindow!.webContents.send('openFiles', paths);

const isStoreBuild = process.windowsStore || process.mas;
let apiKeyboardActionRequestsId = 0;
const apiKeyboardActionRequests = new Map<number, () => void>();

async function sendApiKeyboardAction(action: string) {
try {
const id = apiKeyboardActionRequestsId;
apiKeyboardActionRequestsId += 1;
mainWindow!.webContents.send('apiKeyboardAction', { id, action } satisfies ApiKeyboardActionRequest);
await new Promise<void>((resolve) => {
apiKeyboardActionRequests.set(id, resolve);
});
} catch (err) {
logger.error('sendApiKeyboardAction', err);
}
}

// https://github.com/electron/electron/issues/526#issuecomment-563010533
function getSizeOptions() {
const bounds = configStore.get('windowBounds');
const options = {};
const options: BrowserWindowConstructorOptions = {};
if (bounds) {
const area = electron.screen.getDisplayMatching(bounds).workArea;
// If the saved position still valid (the window is entirely inside the display area), use it.
Expand Down Expand Up @@ -82,7 +158,6 @@ function createWindow() {
...getSizeOptions(),
darkTheme: true,
webPreferences: {
enableRemoteModule: true,
contextIsolation: false,
nodeIntegration: true,
// https://github.com/electron/electron/issues/5107
Expand All @@ -93,10 +168,11 @@ function createWindow() {

remote.enable(mainWindow.webContents);

attachContextMenu(mainWindow);

if (isDev) mainWindow.loadURL('http://localhost:3001');
// Need to useloadFile for special characters https://github.com/mifi/lossless-cut/issues/40
else mainWindow.loadFile(`${frontendBuildDir}/index.html`);
else mainWindow.loadFile('out/renderer/index.html');

// Open the DevTools.
// mainWindow.webContents.openDevTools()
Expand All @@ -113,6 +189,7 @@ function createWindow() {
mainWindow.on('close', (e) => {
if (!askBeforeClose) return;

assert(mainWindow);
const choice = electron.dialog.showMessageBoxSync(mainWindow, {
type: 'question',
buttons: ['Yes', 'No'],
Expand All @@ -135,10 +212,11 @@ function createWindow() {
}

function updateMenu() {
assert(mainWindow);
menu({ app, mainWindow, newVersion, isStoreBuild });
}

function openFilesEventually(paths) {
function openFilesEventually(paths: string[]) {
if (rendererReady) openFiles(paths);
else filesToOpen = paths;
}
Expand All @@ -150,18 +228,21 @@ function openFilesEventually(paths) {
function parseCliArgs(rawArgv = process.argv) {
const ignoreFirstArgs = process.defaultApp ? 2 : 1;
// production: First arg is the LosslessCut executable
// dev: First 2 args are electron and the electron.js
// dev: First 2 args are electron and the index.js
const argsWithoutAppName = rawArgv.length > ignoreFirstArgs ? rawArgv.slice(ignoreFirstArgs) : [];

return yargsParser(argsWithoutAppName, { boolean: ['allow-multiple-instances', 'disable-networking'] });
return yargsParser(argsWithoutAppName, {
boolean: ['allow-multiple-instances', 'disable-networking'],
string: ['settings-json', 'config-dir'],
});
}

const argv = parseCliArgs();

if (argv.localesPath != null) i18nCommon.setCustomLocalesPath(argv.localesPath);
if (argv['localesPath'] != null) i18nCommon.setCustomLocalesPath(argv['localesPath']);


function safeRequestSingleInstanceLock(additionalData) {
function safeRequestSingleInstanceLock(additionalData: Record<string, unknown>) {
if (process.mas) return true; // todo remove when fixed https://github.com/electron/electron/issues/35540

// using additionalData because the built in "argv" passing is a bit broken:
Expand All @@ -174,17 +255,21 @@ function initApp() {
// However when users start your app in command line, the system's single instance mechanism will be bypassed, and you have to use this method to ensure single instance.
// This can be tested with one terminal: npx electron .
// and another terminal: npx electron . path/to/file.mp4
app.on('second-instance', (event, commandLine, workingDirectory, additionalData) => {
app.on('second-instance', (_event, _commandLine, _workingDirectory, additionalData) => {
// Someone tried to run a second instance, we should focus our window.
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}

if (!Array.isArray(additionalData?.argv)) return;
if (!(additionalData != null && typeof additionalData === 'object' && 'argv' in additionalData) || !Array.isArray(additionalData.argv)) return;

const argv2 = parseCliArgs(additionalData.argv);
if (argv2._) openFilesEventually(argv2._);

logger.info('second-instance', argv2);

if (argv2._ && argv2._.length > 0) openFilesEventually(argv2._.map(String));
else if (argv2['keyboardAction']) sendApiKeyboardAction(argv2['keyboardAction']);
});

// Quit when all windows are closed.
Expand Down Expand Up @@ -212,22 +297,29 @@ function initApp() {
event.preventDefault(); // recommended in docs https://www.electronjs.org/docs/latest/api/app#event-open-file-macos
});

ipcMain.on('setAskBeforeClose', (e, val) => {
ipcMain.on('setAskBeforeClose', (_e, val) => {
askBeforeClose = val;
});

ipcMain.on('setLanguage', (e, language) => {
ipcMain.on('setLanguage', (_e, language) => {
i18n.changeLanguage(language).then(() => updateMenu()).catch((err) => logger.error('Failed to set language', err));
});

ipcMain.handle('tryTrashItem', async (e, path) => {
ipcMain.handle('tryTrashItem', async (_e, path) => {
try {
await stat(path);
} catch (err) {
// @ts-expect-error todo
if (err.code === 'ENOENT') return;
}
await shell.trashItem(path);
});

ipcMain.handle('showItemInFolder', (_e, path) => shell.showItemInFolder(path));

ipcMain.on('apiKeyboardActionResponse', (_e, { id }) => {
apiKeyboardActionRequests.get(id)?.();
});
}


Expand All @@ -237,13 +329,11 @@ function initApp() {
// Call this immediately, to make sure we don't miss it (race condition)
const readyPromise = app.whenReady();

// eslint-disable-next-line unicorn/prefer-top-level-await
(async () => {
try {
logger.info('Initializing config store');
await configStore.init();

// todo remove backwards compat:
if (argv.allowMultipleInstances) configStore.set('allowMultipleInstances', true);
await configStore.init({ customConfigDir: argv['configDir'] });

const allowMultipleInstances = configStore.get('allowMultipleInstances');

Expand All @@ -260,24 +350,36 @@ const readyPromise = app.whenReady();

logger.info('CLI arguments', argv);
// Only if no files to open already (open-file might have already added some files)
if (filesToOpen.length === 0) filesToOpen = argv._;
if (filesToOpen.length === 0) filesToOpen = argv._.map(String);
const { settingsJson } = argv;

({ disableNetworking } = argv);

if (settingsJson != null) {
logger.info('initializing settings', settingsJson);
Object.entries(JSON5.parse(settingsJson)).forEach(([key, value]) => {
// @ts-expect-error todo use zod?
configStore.set(key, value);
});
}

const { httpApi } = argv;

if (httpApi != null) {
const port = typeof httpApi === 'number' ? httpApi : 8080;
const { startHttpServer } = HttpServer({ port, onKeyboardAction: sendApiKeyboardAction });
await startHttpServer();
logger.info('HTTP API listening on port', port);
}


if (isDev) {
const { default: installExtension, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer'); // eslint-disable-line global-require,import/no-extraneous-dependencies
// eslint-disable-next-line @typescript-eslint/no-var-requires,global-require,import/no-extraneous-dependencies
const { default: installExtension, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer');

installExtension(REACT_DEVELOPER_TOOLS)
.then(name => logger.info('Added Extension', name))
.catch(err => logger.error('Failed to add extension', err));
.then((name: string) => logger.info('Added Extension', name))
.catch((err: unknown) => logger.error('Failed to add extension', err));
}

createWindow();
Expand All @@ -295,14 +397,16 @@ const readyPromise = app.whenReady();
}
})();

function focusWindow() {
export function focusWindow() {
try {
app.focus({ steal: true });
} catch (err) {
logger.error('Failed to focus window', err);
}
}

const hasDisabledNetworking = () => !!disableNetworking;
export function quitApp() {
electron.app.quit();
}

module.exports = { focusWindow, isDev, hasDisabledNetworking };
export const hasDisabledNetworking = () => !!disableNetworking;
2 changes: 2 additions & 0 deletions src/main/isDev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const isDev = import.meta.env.MODE === 'development';
export default isDev;
38 changes: 38 additions & 0 deletions src/main/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import winston from 'winston';
import util from 'node:util';
// eslint-disable-next-line import/no-extraneous-dependencies
import { app } from 'electron';
import { join } from 'node:path';
// eslint-disable-next-line import/no-extraneous-dependencies
import type { TransformableInfo } from 'logform';


// https://mifi.no/blog/winston-electron-logger/

// https://github.com/winstonjs/winston/issues/1427
const combineMessageAndSplat = () => ({
transform(info: TransformableInfo) {
// @ts-expect-error todo
const { [Symbol.for('splat')]: args = [], message } = info;
// eslint-disable-next-line no-param-reassign
info.message = util.format(message, ...args);
return info;
},
});

const createLogger = () => winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
combineMessageAndSplat(),
winston.format.printf((info) => `${info['timestamp']} ${info.level}: ${info.message}`),
),
});

const logDirPath = app.isPackaged ? app.getPath('userData') : '.';
export const logFilePath = join(logDirPath, 'app.log');

const logger = createLogger();
logger.add(new winston.transports.Console());
logger.add(new winston.transports.File({ level: 'debug', filename: logFilePath, options: { flags: 'a' }, maxsize: 1e6, maxFiles: 100, tailable: true }));

export default logger;
271 changes: 170 additions & 101 deletions public/menu.js → src/main/menu.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
const electron = require('electron');
const { t } = require('i18next');
// eslint-disable-next-line import/no-extraneous-dependencies
import electron, { BrowserWindow, MenuItem, MenuItemConstructorOptions } from 'electron';
import { t } from 'i18next';

import { homepage, getReleaseUrl, licensesPage } from './constants.js';
import { logFilePath } from './logger.js';


// menu-safe i18n.t:
// https://github.com/mifi/lossless-cut/issues/1456
const esc = (val) => val.replace(/&/g, '&&');
const esc = (val: string) => val.replaceAll('&', '&&');

const { Menu } = electron;

const { homepage, getReleaseUrl, licensesPage } = require('./constants');

module.exports = ({ app, mainWindow, newVersion, isStoreBuild }) => {
const menu = [
...(process.platform === 'darwin' ? [{ role: 'appMenu' }] : []),
export default ({ app, mainWindow, newVersion, isStoreBuild }: {
app: Electron.App, mainWindow: BrowserWindow, newVersion?: string | undefined, isStoreBuild: boolean,
}) => {
// todo TS mainWindow.webContents.send
const menu: (MenuItemConstructorOptions | MenuItem)[] = [
...(process.platform === 'darwin' ? [{ role: 'appMenu' as const }] : []),

{
label: esc(t('File')),
Expand All @@ -23,6 +29,12 @@ module.exports = ({ app, mainWindow, newVersion, isStoreBuild }) => {
mainWindow.webContents.send('openFilesDialog');
},
},
{
label: esc(t('Open folder')),
async click() {
mainWindow.webContents.send('openDirDialog');
},
},
{
label: esc(t('Close')),
accelerator: 'CmdOrCtrl+W',
Expand All @@ -33,7 +45,7 @@ module.exports = ({ app, mainWindow, newVersion, isStoreBuild }) => {
{
label: esc(t('Close batch')),
async click() {
mainWindow.webContents.send('closeBatchFiles');
mainWindow.webContents.send('closeBatch');
},
},
{ type: 'separator' },
Expand Down Expand Up @@ -64,6 +76,12 @@ module.exports = ({ app, mainWindow, newVersion, isStoreBuild }) => {
mainWindow.webContents.send('importEdlFile', 'csv-frames');
},
},
{
label: esc(t('Cutlist')),
click() {
mainWindow.webContents.send('importEdlFile', 'cutlist');
},
},
{
label: esc(t('EDL (MPlayer)')),
click() {
Expand Down Expand Up @@ -100,6 +118,18 @@ module.exports = ({ app, mainWindow, newVersion, isStoreBuild }) => {
mainWindow.webContents.send('importEdlFile', 'pbf');
},
},
{
label: esc(t('Subtitles (SRT)')),
click() {
mainWindow.webContents.send('importEdlFile', 'srt');
},
},
{
label: esc(t('DV Analyzer Summary.txt')),
click() {
mainWindow.webContents.send('importEdlFile', 'dv-analyzer-summary-txt');
},
},
],
},
{
Expand Down Expand Up @@ -129,10 +159,16 @@ module.exports = ({ app, mainWindow, newVersion, isStoreBuild }) => {
mainWindow.webContents.send('exportEdlFile', 'tsv-human');
},
},
{
label: esc(t('Subtitles (SRT)')),
click() {
mainWindow.webContents.send('exportEdlFile', 'srt');
},
},
{
label: esc(t('Start times as YouTube Chapters')),
click() {
mainWindow.webContents.send('exportEdlYouTube');
mainWindow.webContents.send('exportYouTube');
},
},
],
Expand Down Expand Up @@ -163,7 +199,7 @@ module.exports = ({ app, mainWindow, newVersion, isStoreBuild }) => {
// Due to Apple Review Guidelines, we cannot include an Exit menu item here
// Apple has their own Quit from the app menu
...(process.platform !== 'darwin' ? [
{ type: 'separator' },
{ type: 'separator' } as const,
{
label: esc(t('Exit')),
click() {
Expand All @@ -185,79 +221,8 @@ module.exports = ({ app, mainWindow, newVersion, isStoreBuild }) => {
{ role: 'cut', label: esc(t('Cut')) },
{ role: 'copy', label: esc(t('Copy')) },
{ role: 'paste', label: esc(t('Paste')) },
{ role: 'selectall', label: esc(t('Select All')) },
{ role: 'selectAll', label: esc(t('Select All')) },
{ type: 'separator' },
{
label: esc(t('Segments')),
submenu: [
{
label: esc(t('Clear all segments')),
click() {
mainWindow.webContents.send('clearSegments');
},
},
{
label: esc(t('Reorder segments by start time')),
click() {
mainWindow.webContents.send('reorderSegsByStartTime');
},
},
{
label: esc(t('Create num segments')),
click() {
mainWindow.webContents.send('createNumSegments');
},
},
{
label: esc(t('Create fixed duration segments')),
click() {
mainWindow.webContents.send('createFixedDurationSegments');
},
},
{
label: esc(t('Create random segments')),
click() {
mainWindow.webContents.send('createRandomSegments');
},
},
{
label: esc(t('Invert all segments on timeline')),
click() {
mainWindow.webContents.send('invertAllSegments');
},
},
{
label: esc(t('Fill gaps between segments')),
click() {
mainWindow.webContents.send('fillSegmentsGaps');
},
},
{
label: esc(t('Combine overlapping segments')),
click() {
mainWindow.webContents.send('combineOverlappingSegments');
},
},
{
label: esc(t('Shuffle segments order')),
click() {
mainWindow.webContents.send('shuffleSegments');
},
},
{
label: esc(t('Shift all segments on timeline')),
click() {
mainWindow.webContents.send('shiftAllSegmentTimes');
},
},
{
label: esc(t('Align segment times to keyframes')),
click() {
mainWindow.webContents.send('alignSegmentTimesToKeyframes');
},
},
],
},
{
label: esc(t('Tracks')),
submenu: [
Expand All @@ -278,19 +243,118 @@ module.exports = ({ app, mainWindow, newVersion, isStoreBuild }) => {
],
},

{
label: esc(t('Segments')),
submenu: [
{
label: esc(t('Create num segments')),
click() {
mainWindow.webContents.send('createNumSegments');
},
},
{
label: esc(t('Create fixed duration segments')),
click() {
mainWindow.webContents.send('createFixedDurationSegments');
},
},
{
label: esc(t('Create random segments')),
click() {
mainWindow.webContents.send('createRandomSegments');
},
},

{ type: 'separator' },

{
label: esc(t('Reorder segments by start time')),
click() {
mainWindow.webContents.send('reorderSegsByStartTime');
},
},
{
label: esc(t('Shuffle segments order')),
click() {
mainWindow.webContents.send('shuffleSegments');
},
},

{ type: 'separator' },

{
label: esc(t('Combine overlapping segments')),
click() {
mainWindow.webContents.send('combineOverlappingSegments');
},
},
{
label: esc(t('Combine selected segments')),
click() {
mainWindow.webContents.send('combineSelectedSegments');
},
},
{
label: esc(t('Split segment at cursor')),
click() {
mainWindow.webContents.send('splitCurrentSegment');
},
},
{
label: esc(t('Invert all segments on timeline')),
click() {
mainWindow.webContents.send('invertAllSegments');
},
},
{
label: esc(t('Fill gaps between segments')),
click() {
mainWindow.webContents.send('fillSegmentsGaps');
},
},

{ type: 'separator' },

{
label: esc(t('Shift all segments on timeline')),
click() {
mainWindow.webContents.send('shiftAllSegmentTimes');
},
},
{
label: esc(t('Align segment times to keyframes')),
click() {
mainWindow.webContents.send('alignSegmentTimesToKeyframes');
},
},

{ type: 'separator' },

{
label: esc(t('Clear all segments')),
click() {
mainWindow.webContents.send('clearSegments');
},
},
],
},

{
label: esc(t('View')),
submenu: [
{ role: 'togglefullscreen', label: esc(t('Toggle Full Screen')) },
{ role: 'resetZoom', label: esc(t('Reset font size')) },
{ role: 'zoomIn', label: esc(t('Increase font size')) },
{ role: 'zoomOut', label: esc(t('Decrease font size')) },
],
},

// On Windows the windowMenu has a close Ctrl+W which clashes with File->Close shortcut
...(process.platform === 'darwin'
? [{ role: 'windowMenu', label: esc(t('Window')) }]
? [{ role: 'windowMenu' as const, label: esc(t('Window')) }]
: [{
label: esc(t('Window')),
submenu: [{ role: 'minimize', label: esc(t('Minimize')) }],
submenu: [{ role: 'minimize' as const, label: esc(t('Minimize')) }],
}]
),

Expand All @@ -300,13 +364,13 @@ module.exports = ({ app, mainWindow, newVersion, isStoreBuild }) => {
{
label: esc(t('Merge/concatenate files')),
click() {
mainWindow.webContents.send('concatCurrentBatch');
mainWindow.webContents.send('concatBatch');
},
},
{
label: esc(t('Set custom start offset/timecode')),
click() {
mainWindow.webContents.send('askSetStartTimeOffset');
mainWindow.webContents.send('setStartTimeOffset');
},
},
{
Expand Down Expand Up @@ -357,31 +421,36 @@ module.exports = ({ app, mainWindow, newVersion, isStoreBuild }) => {
label: esc(t('Troubleshooting')),
click() { electron.shell.openExternal('https://mifi.no/losslesscut/troubleshooting'); },
},
{
label: esc(t('Keyboard & mouse shortcuts')),
click() {
mainWindow.webContents.send('toggleKeyboardShortcuts');
},
},
{
label: esc(t('Learn More')),
click() { electron.shell.openExternal(homepage); },
},
{ type: 'separator' },
{
label: esc(t('Licenses')),
click() { electron.shell.openExternal(licensesPage); },
label: esc(t('Report an error')),
click() { mainWindow.webContents.send('openSendReportDialog'); },
},
{
label: esc(t('Feature request')),
click() { electron.shell.openExternal('https://github.com/mifi/lossless-cut/issues'); },
},
{ type: 'separator' },
{
label: esc(t('Keyboard & mouse shortcuts')),
click() {
mainWindow.webContents.send('toggleKeyboardShortcuts');
},
label: esc(t('Log file')),
click() { electron.shell.openPath(logFilePath); },
},
{ type: 'separator' },
{
label: esc(t('Report an error')),
click() { mainWindow.webContents.send('openSendReportDialog'); },
label: esc(t('Licenses')),
click() { electron.shell.openExternal(licensesPage); },
},
...(!isStoreBuild ? [
{
label: esc(t('Version')),
click() { mainWindow.webContents.send('openAbout'); },
},
] : []),
...(process.platform !== 'darwin' ? [{ role: 'about' as const, label: esc(t('About LosslessCut')) }] : []),
],
},
];
Expand Down
50 changes: 50 additions & 0 deletions src/main/pathToFileURL.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// eslint-disable-line unicorn/filename-case
// eslint-disable-next-line import/no-extraneous-dependencies
import { test, expect, describe } from 'vitest';

import { pathToFileURL } from 'node:url';


if (process.platform === 'win32') {
describe('file uri windows only', () => {
test('converts path to file url', () => {
expect(pathToFileURL('C:\\Users\\sindresorhus\\dev\\te^st.jpg').href).toEqual('file:///C:/Users/sindresorhus/dev/te^st.jpg');
});
});
} else {
describe('file uri non-windows', () => {
// https://github.com/mifi/lossless-cut/issues/1941
test('file with backslash', () => {
expect(pathToFileURL('/has/back\\slash').href).toEqual('file:///has/back%5Cslash');
});
});
}

// taken from https://github.com/sindresorhus/file-url
describe('file uri both platforms', () => {
test('converts path to file url', () => {
expect(pathToFileURL('/test.jpg').href).toMatch(/^file:\/{3}.*test\.jpg$/);

expect(pathToFileURL('/Users/sindresorhus/dev/te^st.jpg').href).toMatch(/^file:\/{2}.*\/Users\/sindresorhus\/dev\/te\^st\.jpg$/);
});

test('escapes more special characters in path', () => {
expect(pathToFileURL('/a^?!@#$%&\'";<>').href).toMatch(/^file:\/{3}.*a\^%3F!@%23\$%25&'%22;%3C%3E$/);
});

test('escapes whitespace characters in path', () => {
expect(pathToFileURL('/file with\r\nnewline').href).toMatch(/^file:\/{3}.*file%20with%0D%0Anewline$/);
});

test('relative path', () => {
expect(pathToFileURL('relative/test.jpg').href).toMatch(/^file:\/{3}.*\/relative\/test\.jpg$/);
});

test('slash', () => {
expect(pathToFileURL('/').href).toMatch(/^file:\/{2}.*\/$/);
});

test('empty', () => {
expect(pathToFileURL('').href).toMatch(/^file:\/{3}.*$/);
});
});
31 changes: 20 additions & 11 deletions public/update-checker.js → src/main/updateChecker.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
const GitHub = require('github-api');
// eslint-disable-next-line import/no-extraneous-dependencies
const electron = require('electron');
const semver = require('semver');
import electron from 'electron';
import semver from 'semver';
// eslint-disable-next-line import/no-extraneous-dependencies
import { Octokit } from '@octokit/core';

const logger = require('./logger');
import logger from './logger.js';


const { app } = electron;

const gh = new GitHub();
const repo = gh.getRepo('mifi', 'lossless-cut');
const octokit = new Octokit();


async function checkNewVersion() {
// eslint-disable-next-line import/prefer-default-export
export async function checkNewVersion() {
try {
// From API: https://developer.github.com/v3/repos/releases/#get-the-latest-release
// View the latest published full release for the repository.
// Draft releases and prereleases are not returned by this endpoint.
const res = (await repo.getRelease('latest')).data;
const newestVersion = res.tag_name.replace(/^v?/, '');

const { data } = await octokit.request('GET /repos/{owner}/{repo}/releases/latest', {
owner: 'mifi',
repo: 'lossless-cut',
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
});

const newestVersion = data.tag_name.replace(/^v?/, '');

const currentVersion = app.getVersion();
// const currentVersion = '3.17.2';
Expand All @@ -28,9 +38,8 @@ async function checkNewVersion() {
if (semver.lt(currentVersion, newestVersion)) return newestVersion;
return undefined;
} catch (err) {
// @ts-expect-error todo
logger.error('Failed to check github version', err.message);
return undefined;
}
}

module.exports = { checkNewVersion };
8 changes: 8 additions & 0 deletions src/main/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import os from 'node:os';

export const platform = os.platform();
export const arch = os.arch();

export const isWindows = platform === 'win32';
export const isMac = platform === 'darwin';
export const isLinux = platform === 'linux';
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// todo
console.log('preload');
2 changes: 1 addition & 1 deletion index.html → src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
File renamed without changes.
Loading