You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-11-23 22:36:32 +02:00
Desktop: Add support for multiple instances (#11963)
This commit is contained in:
@@ -158,6 +158,7 @@ packages/app-desktop/commands/exportFolders.js
|
|||||||
packages/app-desktop/commands/exportNotes.js
|
packages/app-desktop/commands/exportNotes.js
|
||||||
packages/app-desktop/commands/focusElement.js
|
packages/app-desktop/commands/focusElement.js
|
||||||
packages/app-desktop/commands/index.js
|
packages/app-desktop/commands/index.js
|
||||||
|
packages/app-desktop/commands/newAppInstance.js
|
||||||
packages/app-desktop/commands/openNoteInNewWindow.js
|
packages/app-desktop/commands/openNoteInNewWindow.js
|
||||||
packages/app-desktop/commands/openProfileDirectory.js
|
packages/app-desktop/commands/openProfileDirectory.js
|
||||||
packages/app-desktop/commands/replaceMisspelling.js
|
packages/app-desktop/commands/replaceMisspelling.js
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ module.exports = {
|
|||||||
'tinymce': 'readonly',
|
'tinymce': 'readonly',
|
||||||
|
|
||||||
'JSX': 'readonly',
|
'JSX': 'readonly',
|
||||||
|
|
||||||
|
'NodeJS': 'readonly',
|
||||||
},
|
},
|
||||||
'parserOptions': {
|
'parserOptions': {
|
||||||
'ecmaVersion': 2018,
|
'ecmaVersion': 2018,
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -133,6 +133,7 @@ packages/app-desktop/commands/exportFolders.js
|
|||||||
packages/app-desktop/commands/exportNotes.js
|
packages/app-desktop/commands/exportNotes.js
|
||||||
packages/app-desktop/commands/focusElement.js
|
packages/app-desktop/commands/focusElement.js
|
||||||
packages/app-desktop/commands/index.js
|
packages/app-desktop/commands/index.js
|
||||||
|
packages/app-desktop/commands/newAppInstance.js
|
||||||
packages/app-desktop/commands/openNoteInNewWindow.js
|
packages/app-desktop/commands/openNoteInNewWindow.js
|
||||||
packages/app-desktop/commands/openProfileDirectory.js
|
packages/app-desktop/commands/openProfileDirectory.js
|
||||||
packages/app-desktop/commands/replaceMisspelling.js
|
packages/app-desktop/commands/replaceMisspelling.js
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import Logger, { LoggerWrapper } from '@joplin/utils/Logger';
|
import Logger, { LoggerWrapper, TargetType } from '@joplin/utils/Logger';
|
||||||
import { PluginMessage } from './services/plugins/PluginRunner';
|
import { PluginMessage } from './services/plugins/PluginRunner';
|
||||||
import AutoUpdaterService, { defaultUpdateInterval, initialUpdateStartup } from './services/autoUpdater/AutoUpdaterService';
|
import AutoUpdaterService, { defaultUpdateInterval, initialUpdateStartup } from './services/autoUpdater/AutoUpdaterService';
|
||||||
import type ShimType from '@joplin/lib/shim';
|
import type ShimType from '@joplin/lib/shim';
|
||||||
const shim: typeof ShimType = require('@joplin/lib/shim').default;
|
const shim: typeof ShimType = require('@joplin/lib/shim').default;
|
||||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||||
|
import { FileLocker } from '@joplin/utils/fs';
|
||||||
import { BrowserWindow, Tray, WebContents, screen } from 'electron';
|
import { IpcMessageHandler, IpcServer, Message, newHttpError, sendMessage, SendMessageOptions, startServer, stopServer } from '@joplin/utils/ipc';
|
||||||
|
import { BrowserWindow, Tray, WebContents, screen, App } from 'electron';
|
||||||
import bridge from './bridge';
|
import bridge from './bridge';
|
||||||
const url = require('url');
|
const url = require('url');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -19,6 +20,7 @@ import handleCustomProtocols, { CustomProtocolHandler } from './utils/customProt
|
|||||||
import { clearTimeout, setTimeout } from 'timers';
|
import { clearTimeout, setTimeout } from 'timers';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||||
|
import { msleep } from '@joplin/utils/time';
|
||||||
|
|
||||||
interface RendererProcessQuitReply {
|
interface RendererProcessQuitReply {
|
||||||
canClose: boolean;
|
canClose: boolean;
|
||||||
@@ -36,8 +38,7 @@ interface SecondaryWindowData {
|
|||||||
|
|
||||||
export default class ElectronAppWrapper {
|
export default class ElectronAppWrapper {
|
||||||
private logger_: Logger = null;
|
private logger_: Logger = null;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
private electronApp_: App;
|
||||||
private electronApp_: any;
|
|
||||||
private env_: string;
|
private env_: string;
|
||||||
private isDebugMode_: boolean;
|
private isDebugMode_: boolean;
|
||||||
private profilePath_: string;
|
private profilePath_: string;
|
||||||
@@ -58,13 +59,28 @@ export default class ElectronAppWrapper {
|
|||||||
private customProtocolHandler_: CustomProtocolHandler = null;
|
private customProtocolHandler_: CustomProtocolHandler = null;
|
||||||
private updatePollInterval_: ReturnType<typeof setTimeout>|null = null;
|
private updatePollInterval_: ReturnType<typeof setTimeout>|null = null;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
private profileLocker_: FileLocker|null = null;
|
||||||
public constructor(electronApp: any, env: string, profilePath: string|null, isDebugMode: boolean, initialCallbackUrl: string) {
|
private ipcServer_: IpcServer|null = null;
|
||||||
|
private ipcStartPort_ = 2658;
|
||||||
|
|
||||||
|
private ipcLogger_: Logger;
|
||||||
|
|
||||||
|
public constructor(electronApp: App, env: string, profilePath: string|null, isDebugMode: boolean, initialCallbackUrl: string) {
|
||||||
this.electronApp_ = electronApp;
|
this.electronApp_ = electronApp;
|
||||||
this.env_ = env;
|
this.env_ = env;
|
||||||
this.isDebugMode_ = isDebugMode;
|
this.isDebugMode_ = isDebugMode;
|
||||||
this.profilePath_ = profilePath;
|
this.profilePath_ = profilePath;
|
||||||
this.initialCallbackUrl_ = initialCallbackUrl;
|
this.initialCallbackUrl_ = initialCallbackUrl;
|
||||||
|
|
||||||
|
this.profileLocker_ = new FileLocker(`${this.profilePath_}/lock`);
|
||||||
|
|
||||||
|
// Note: in certain contexts `this.logger_` doesn't seem to be available, especially for IPC
|
||||||
|
// calls, either because it hasn't been set or other issue. So we set one here specifically
|
||||||
|
// for this.
|
||||||
|
this.ipcLogger_ = new Logger();
|
||||||
|
this.ipcLogger_.addTarget(TargetType.File, {
|
||||||
|
path: `${profilePath}/log-cross-app-ipc.txt`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public electronApp() {
|
public electronApp() {
|
||||||
@@ -410,7 +426,7 @@ export default class ElectronAppWrapper {
|
|||||||
if (message.target === 'plugin') {
|
if (message.target === 'plugin') {
|
||||||
const win = this.pluginWindows_[message.pluginId];
|
const win = this.pluginWindows_[message.pluginId];
|
||||||
if (!win) {
|
if (!win) {
|
||||||
this.logger().error(`Trying to send IPC message to non-existing plugin window: ${message.pluginId}`);
|
this.ipcLogger_.error(`Trying to send IPC message to non-existing plugin window: ${message.pluginId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,12 +481,24 @@ export default class ElectronAppWrapper {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public quit() {
|
private onExit() {
|
||||||
this.stopPeriodicUpdateCheck();
|
this.stopPeriodicUpdateCheck();
|
||||||
|
this.profileLocker_.unlockSync();
|
||||||
|
|
||||||
|
// Probably doesn't matter if the server is not closed cleanly? Thus the lack of `await`
|
||||||
|
// eslint-disable-next-line promise/prefer-await-to-then -- Needed here because onExit() is not async
|
||||||
|
void stopServer(this.ipcServer_).catch(_error => {
|
||||||
|
// Ignore it since we're stopping, and to prevent unnecessary messages.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public quit() {
|
||||||
|
this.onExit();
|
||||||
this.electronApp_.quit();
|
this.electronApp_.quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
public exit(errorCode = 0) {
|
public exit(errorCode = 0) {
|
||||||
|
this.onExit();
|
||||||
this.electronApp_.exit(errorCode);
|
this.electronApp_.exit(errorCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,20 +564,26 @@ export default class ElectronAppWrapper {
|
|||||||
this.tray_ = null;
|
this.tray_ = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ensureSingleInstance() {
|
public async sendCrossAppIpcMessage(message: Message, port: number|null = null, options: SendMessageOptions = null) {
|
||||||
if (this.env_ === 'dev') return false;
|
this.ipcLogger_.info('Sending message:', message);
|
||||||
|
|
||||||
const gotTheLock = this.electronApp_.requestSingleInstanceLock();
|
if (port === null) port = this.ipcStartPort_;
|
||||||
|
|
||||||
if (!gotTheLock) {
|
return await sendMessage(port, { ...message, sourcePort: this.ipcServer_.port }, {
|
||||||
// Another instance is already running - exit
|
logger: this.ipcLogger_,
|
||||||
this.quit();
|
...options,
|
||||||
return true;
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ensureSingleInstance() {
|
||||||
|
// if (this.env_ === 'dev') return false;
|
||||||
|
|
||||||
|
interface OnSecondInstanceMessageData {
|
||||||
|
profilePath: string;
|
||||||
|
argv: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Someone tried to open a second instance - focus our window instead
|
const activateWindow = (argv: string[]) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
||||||
this.electronApp_.on('second-instance', (_e: any, argv: string[]) => {
|
|
||||||
const win = this.mainWindow();
|
const win = this.mainWindow();
|
||||||
if (!win) return;
|
if (!win) return;
|
||||||
if (win.isMinimized()) win.restore();
|
if (win.isMinimized()) win.restore();
|
||||||
@@ -562,9 +596,85 @@ export default class ElectronAppWrapper {
|
|||||||
void this.openCallbackUrl(url);
|
void this.openCallbackUrl(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageHandlers: Record<string, IpcMessageHandler> = {
|
||||||
|
'onSecondInstance': async (message) => {
|
||||||
|
const data = message.data as OnSecondInstanceMessageData;
|
||||||
|
if (data.profilePath === this.profilePath_) activateWindow(data.argv);
|
||||||
|
},
|
||||||
|
|
||||||
|
'restartAltInstance': async (message) => {
|
||||||
|
if (bridge().altInstanceId()) return false;
|
||||||
|
|
||||||
|
// We do this in a timeout after a short interval because we need this call to
|
||||||
|
// return the response immediately, so that the caller can call `quit()`
|
||||||
|
setTimeout(async () => {
|
||||||
|
const maxWait = 10000;
|
||||||
|
const interval = 300;
|
||||||
|
const loopCount = Math.ceil(maxWait / interval);
|
||||||
|
let callingAppGone = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < loopCount; i++) {
|
||||||
|
const response = await this.sendCrossAppIpcMessage({
|
||||||
|
action: 'ping',
|
||||||
|
data: null,
|
||||||
|
}, message.sourcePort, {
|
||||||
|
sendToSpecificPortOnly: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.length) {
|
||||||
|
callingAppGone = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await msleep(interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callingAppGone) {
|
||||||
|
this.ipcLogger_.warn('restartAltInstance: App is gone - restarting it');
|
||||||
|
void bridge().launchNewAppInstance(this.env());
|
||||||
|
} else {
|
||||||
|
this.ipcLogger_.warn('restartAltInstance: Could not restart calling app because it was still open');
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
'ping': async (_message) => {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ipcServer_ = await startServer(this.ipcStartPort_, async (message) => {
|
||||||
|
if (messageHandlers[message.action]) {
|
||||||
|
this.ipcLogger_.info('Got message:', message);
|
||||||
|
return messageHandlers[message.action](message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw newHttpError(404);
|
||||||
|
}, {
|
||||||
|
logger: this.ipcLogger_,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
// First check that no other app is running from that profile folder
|
||||||
|
const gotAppLock = await this.profileLocker_.lock();
|
||||||
|
if (gotAppLock) return false;
|
||||||
|
|
||||||
|
const message: Message = {
|
||||||
|
action: 'onSecondInstance',
|
||||||
|
data: {
|
||||||
|
senderPort: this.ipcServer_.port,
|
||||||
|
profilePath: this.profilePath_,
|
||||||
|
argv: process.argv,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.sendCrossAppIpcMessage(message);
|
||||||
|
|
||||||
|
this.quit();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public initializeCustomProtocolHandler(logger: LoggerWrapper) {
|
public initializeCustomProtocolHandler(logger: LoggerWrapper) {
|
||||||
@@ -606,7 +716,7 @@ export default class ElectronAppWrapper {
|
|||||||
// the "ready" event. So we use the function below to make sure that the app is ready.
|
// the "ready" event. So we use the function below to make sure that the app is ready.
|
||||||
await this.waitForElectronAppReady();
|
await this.waitForElectronAppReady();
|
||||||
|
|
||||||
const alreadyRunning = this.ensureSingleInstance();
|
const alreadyRunning = await this.ensureSingleInstance();
|
||||||
if (alreadyRunning) return;
|
if (alreadyRunning) return;
|
||||||
|
|
||||||
this.createWindow();
|
this.createWindow();
|
||||||
|
|||||||
@@ -617,10 +617,11 @@ class Application extends BaseApplication {
|
|||||||
clipperLogger.addTarget(TargetType.Console);
|
clipperLogger.addTarget(TargetType.Console);
|
||||||
|
|
||||||
ClipperServer.instance().initialize(actionApi);
|
ClipperServer.instance().initialize(actionApi);
|
||||||
|
ClipperServer.instance().setEnabled(!Setting.value('altInstanceId'));
|
||||||
ClipperServer.instance().setLogger(clipperLogger);
|
ClipperServer.instance().setLogger(clipperLogger);
|
||||||
ClipperServer.instance().setDispatch(this.store().dispatch);
|
ClipperServer.instance().setDispatch(this.store().dispatch);
|
||||||
|
|
||||||
if (Setting.value('clipperServer.autoStart')) {
|
if (ClipperServer.instance().enabled() && Setting.value('clipperServer.autoStart')) {
|
||||||
void ClipperServer.instance().start();
|
void ClipperServer.instance().start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import isSafeToOpen from './utils/isSafeToOpen';
|
|||||||
import { closeSync, openSync, readSync, statSync } from 'fs';
|
import { closeSync, openSync, readSync, statSync } from 'fs';
|
||||||
import { KB } from '@joplin/utils/bytes';
|
import { KB } from '@joplin/utils/bytes';
|
||||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||||
|
import { execCommand } from '@joplin/utils';
|
||||||
|
|
||||||
interface LastSelectedPath {
|
interface LastSelectedPath {
|
||||||
file: string;
|
file: string;
|
||||||
@@ -43,16 +44,18 @@ export class Bridge {
|
|||||||
private appName_: string;
|
private appName_: string;
|
||||||
private appId_: string;
|
private appId_: string;
|
||||||
private logFilePath_ = '';
|
private logFilePath_ = '';
|
||||||
|
private altInstanceId_ = '';
|
||||||
|
|
||||||
private extraAllowedExtensions_: string[] = [];
|
private extraAllowedExtensions_: string[] = [];
|
||||||
private onAllowedExtensionsChangeListener_: OnAllowedExtensionsChange = ()=>{};
|
private onAllowedExtensionsChangeListener_: OnAllowedExtensionsChange = ()=>{};
|
||||||
|
|
||||||
public constructor(electronWrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean) {
|
public constructor(electronWrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean, altInstanceId: string) {
|
||||||
this.electronWrapper_ = electronWrapper;
|
this.electronWrapper_ = electronWrapper;
|
||||||
this.appId_ = appId;
|
this.appId_ = appId;
|
||||||
this.appName_ = appName;
|
this.appName_ = appName;
|
||||||
this.rootProfileDir_ = rootProfileDir;
|
this.rootProfileDir_ = rootProfileDir;
|
||||||
this.autoUploadCrashDumps_ = autoUploadCrashDumps;
|
this.autoUploadCrashDumps_ = autoUploadCrashDumps;
|
||||||
|
this.altInstanceId_ = altInstanceId;
|
||||||
this.lastSelectedPaths_ = {
|
this.lastSelectedPaths_ = {
|
||||||
file: null,
|
file: null,
|
||||||
directory: null,
|
directory: null,
|
||||||
@@ -218,6 +221,10 @@ export class Bridge {
|
|||||||
return this.electronApp().electronApp().getLocale();
|
return this.electronApp().electronApp().getLocale();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public altInstanceId() {
|
||||||
|
return this.altInstanceId_;
|
||||||
|
}
|
||||||
|
|
||||||
// Applies to electron-context-menu@3:
|
// Applies to electron-context-menu@3:
|
||||||
//
|
//
|
||||||
// For now we have to disable spell checking in non-editor text
|
// For now we have to disable spell checking in non-editor text
|
||||||
@@ -491,7 +498,38 @@ export class Bridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public restart(linuxSafeRestart = true) {
|
public appLaunchCommand(env: string, altInstanceId = '') {
|
||||||
|
const altInstanceArgs = altInstanceId ? ['--alt-instance-id', altInstanceId] : [];
|
||||||
|
|
||||||
|
if (env === 'dev') {
|
||||||
|
// This is convenient to quickly test on dev, but the path needs to be adjusted
|
||||||
|
// depending on how things are setup.
|
||||||
|
|
||||||
|
return {
|
||||||
|
execPath: `${homedir()}/.npm-global/bin/electron`,
|
||||||
|
args: [
|
||||||
|
`${homedir()}/src/joplin/packages/app-desktop`,
|
||||||
|
'--env', 'dev',
|
||||||
|
'--log-level', 'debug',
|
||||||
|
'--open-dev-tools',
|
||||||
|
'--no-welcome',
|
||||||
|
].concat(altInstanceArgs),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
execPath: bridge().electronApp().electronApp().getPath('exe'),
|
||||||
|
args: [].concat(altInstanceArgs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async launchNewAppInstance(env: string) {
|
||||||
|
const cmd = this.appLaunchCommand(env, 'alt1');
|
||||||
|
|
||||||
|
await execCommand([cmd.execPath].concat(cmd.args), { detached: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async restart() {
|
||||||
// Note that in this case we are not sending the "appClose" event
|
// Note that in this case we are not sending the "appClose" event
|
||||||
// to notify services and component that the app is about to close
|
// to notify services and component that the app is about to close
|
||||||
// but for the current use-case it's not really needed.
|
// but for the current use-case it's not really needed.
|
||||||
@@ -502,8 +540,34 @@ export class Bridge {
|
|||||||
execPath: process.env.PORTABLE_EXECUTABLE_FILE,
|
execPath: process.env.PORTABLE_EXECUTABLE_FILE,
|
||||||
};
|
};
|
||||||
app.relaunch(options);
|
app.relaunch(options);
|
||||||
} else if (shim.isLinux() && linuxSafeRestart) {
|
} else if (this.altInstanceId_) {
|
||||||
this.showInfoMessageBox(_('The app is now going to close. Please relaunch it to complete the process.'));
|
// Couldn't get it to work using relaunch() - it would just "close" the app, but it
|
||||||
|
// would still be open in the tray except unusable. Or maybe it reopens it quickly but
|
||||||
|
// in a broken state. It might be due to the way it is launched from the main instance.
|
||||||
|
// So here we ask the main instance to relaunch this app after a short delay.
|
||||||
|
|
||||||
|
const responses = await this.electronApp().sendCrossAppIpcMessage({
|
||||||
|
action: 'restartAltInstance',
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// However is the main instance is not running, we're stuck, so the user needs to
|
||||||
|
// manually restart. `relaunch()` doesn't appear to work even when the main instance is
|
||||||
|
// not running.
|
||||||
|
const r = responses.find(r => !!r.response);
|
||||||
|
|
||||||
|
if (!r || !r.response) {
|
||||||
|
this.showInfoMessageBox(_('The app is now going to close. Please relaunch it to complete the process.'));
|
||||||
|
|
||||||
|
// Note: this should work, but doesn't:
|
||||||
|
|
||||||
|
// const cmd = this.appLaunchCommand(this.env(), this.altInstanceId_);
|
||||||
|
|
||||||
|
// app.relaunch({
|
||||||
|
// execPath: cmd.execPath,
|
||||||
|
// args: cmd.args,
|
||||||
|
// });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
app.relaunch();
|
app.relaunch();
|
||||||
}
|
}
|
||||||
@@ -534,9 +598,9 @@ export class Bridge {
|
|||||||
|
|
||||||
let bridge_: Bridge = null;
|
let bridge_: Bridge = null;
|
||||||
|
|
||||||
export function initBridge(wrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean) {
|
export function initBridge(wrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean, altInstanceId: string) {
|
||||||
if (bridge_) throw new Error('Bridge already initialized');
|
if (bridge_) throw new Error('Bridge already initialized');
|
||||||
bridge_ = new Bridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps);
|
bridge_ = new Bridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps, altInstanceId);
|
||||||
return bridge_;
|
return bridge_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as exportDeletionLog from './exportDeletionLog';
|
|||||||
import * as exportFolders from './exportFolders';
|
import * as exportFolders from './exportFolders';
|
||||||
import * as exportNotes from './exportNotes';
|
import * as exportNotes from './exportNotes';
|
||||||
import * as focusElement from './focusElement';
|
import * as focusElement from './focusElement';
|
||||||
|
import * as newAppInstance from './newAppInstance';
|
||||||
import * as openNoteInNewWindow from './openNoteInNewWindow';
|
import * as openNoteInNewWindow from './openNoteInNewWindow';
|
||||||
import * as openProfileDirectory from './openProfileDirectory';
|
import * as openProfileDirectory from './openProfileDirectory';
|
||||||
import * as replaceMisspelling from './replaceMisspelling';
|
import * as replaceMisspelling from './replaceMisspelling';
|
||||||
@@ -28,6 +29,7 @@ const index: any[] = [
|
|||||||
exportFolders,
|
exportFolders,
|
||||||
exportNotes,
|
exportNotes,
|
||||||
focusElement,
|
focusElement,
|
||||||
|
newAppInstance,
|
||||||
openNoteInNewWindow,
|
openNoteInNewWindow,
|
||||||
openProfileDirectory,
|
openProfileDirectory,
|
||||||
replaceMisspelling,
|
replaceMisspelling,
|
||||||
|
|||||||
19
packages/app-desktop/commands/newAppInstance.ts
Normal file
19
packages/app-desktop/commands/newAppInstance.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import bridge from '../services/bridge';
|
||||||
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
|
|
||||||
|
export const declaration: CommandDeclaration = {
|
||||||
|
name: 'newAppInstance',
|
||||||
|
label: () => _('New application instance...'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runtime = (): CommandRuntime => {
|
||||||
|
return {
|
||||||
|
execute: async (_context: CommandContext) => {
|
||||||
|
await bridge().launchNewAppInstance(Setting.value('env'));
|
||||||
|
},
|
||||||
|
|
||||||
|
enabledCondition: '!isAltInstance',
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -24,6 +24,7 @@ class ClipperConfigScreenComponent extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enableClipperServer_click() {
|
private enableClipperServer_click() {
|
||||||
|
if (!ClipperServer.instance().enabled()) return;
|
||||||
Setting.setValue('clipperServer.autoStart', true);
|
Setting.setValue('clipperServer.autoStart', true);
|
||||||
void ClipperServer.instance().start();
|
void ClipperServer.instance().start();
|
||||||
}
|
}
|
||||||
@@ -70,6 +71,8 @@ class ClipperConfigScreenComponent extends React.Component {
|
|||||||
|
|
||||||
const webClipperStatusComps = [];
|
const webClipperStatusComps = [];
|
||||||
|
|
||||||
|
const clipperEnabled = ClipperServer.instance().enabled();
|
||||||
|
|
||||||
if (this.props.clipperServerAutoStart) {
|
if (this.props.clipperServerAutoStart) {
|
||||||
webClipperStatusComps.push(
|
webClipperStatusComps.push(
|
||||||
<p key="text_1" style={theme.textStyle}>
|
<p key="text_1" style={theme.textStyle}>
|
||||||
@@ -95,13 +98,22 @@ class ClipperConfigScreenComponent extends React.Component {
|
|||||||
</button>,
|
</button>,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
if (!clipperEnabled) {
|
||||||
|
webClipperStatusComps.push(
|
||||||
|
<p key="text_4" style={theme.textStyle}>
|
||||||
|
{_('The web clipper service cannot be enabled in this instance of Joplin.')}
|
||||||
|
</p>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
webClipperStatusComps.push(
|
||||||
|
<p key="text_4" style={theme.textStyle}>
|
||||||
|
{_('The web clipper service is not enabled.')}
|
||||||
|
</p>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
webClipperStatusComps.push(
|
webClipperStatusComps.push(
|
||||||
<p key="text_4" style={theme.textStyle}>
|
<button key="enable_button" style={buttonStyle} onClick={this.enableClipperServer_click} disabled={!clipperEnabled}>
|
||||||
{_('The web clipper service is not enabled.')}
|
|
||||||
</p>,
|
|
||||||
);
|
|
||||||
webClipperStatusComps.push(
|
|
||||||
<button key="enable_button" style={buttonStyle} onClick={this.enableClipperServer_click}>
|
|
||||||
{_('Enable Web Clipper Service')}
|
{_('Enable Web Clipper Service')}
|
||||||
</button>,
|
</button>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -555,6 +555,7 @@ function useMenu(props: Props) {
|
|||||||
const newFolderItem = menuItemDic.newFolder;
|
const newFolderItem = menuItemDic.newFolder;
|
||||||
const newSubFolderItem = menuItemDic.newSubFolder;
|
const newSubFolderItem = menuItemDic.newSubFolder;
|
||||||
const printItem = menuItemDic.print;
|
const printItem = menuItemDic.print;
|
||||||
|
const newAppInstance = menuItemDic.newAppInstance;
|
||||||
const switchProfileItem = {
|
const switchProfileItem = {
|
||||||
label: _('Switch profile'),
|
label: _('Switch profile'),
|
||||||
submenu: switchProfileMenuItems,
|
submenu: switchProfileMenuItems,
|
||||||
@@ -718,8 +719,11 @@ function useMenu(props: Props) {
|
|||||||
}, {
|
}, {
|
||||||
type: 'separator',
|
type: 'separator',
|
||||||
},
|
},
|
||||||
printItem,
|
printItem, {
|
||||||
|
type: 'separator',
|
||||||
|
},
|
||||||
switchProfileItem,
|
switchProfileItem,
|
||||||
|
newAppInstance,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default function() {
|
|||||||
'toggleTabMovesFocus',
|
'toggleTabMovesFocus',
|
||||||
'editor.deleteLine',
|
'editor.deleteLine',
|
||||||
'editor.duplicateLine',
|
'editor.duplicateLine',
|
||||||
|
'newAppInstance',
|
||||||
// We cannot put the undo/redo commands in the menu because they are
|
// We cannot put the undo/redo commands in the menu because they are
|
||||||
// editor-specific commands. If we put them there it will break the
|
// editor-specific commands. If we put them there it will break the
|
||||||
// undo/redo in regular text fields.
|
// undo/redo in regular text fields.
|
||||||
|
|||||||
@@ -25,28 +25,27 @@ process.on('unhandledRejection', (reason, p) => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Likewise, we want to know if a profile is specified early, in particular
|
const getFlagValueFromArgs = (args, flag, defaultValue) => {
|
||||||
// to save the window state data.
|
|
||||||
function getProfileFromArgs(args) {
|
|
||||||
if (!args) return null;
|
if (!args) return null;
|
||||||
const profileIndex = args.indexOf('--profile');
|
const index = args.indexOf(flag);
|
||||||
if (profileIndex <= 0 || profileIndex >= args.length - 1) return null;
|
if (index <= 0 || index >= args.length - 1) return defaultValue;
|
||||||
const profileValue = args[profileIndex + 1];
|
const value = args[index + 1];
|
||||||
return profileValue ? profileValue : null;
|
return value ? value : defaultValue;
|
||||||
}
|
};
|
||||||
|
|
||||||
Logger.fsDriver_ = new FsDriverNode();
|
Logger.fsDriver_ = new FsDriverNode();
|
||||||
|
|
||||||
const env = envFromArgs(process.argv);
|
const env = envFromArgs(process.argv);
|
||||||
const profileFromArgs = getProfileFromArgs(process.argv);
|
const profileFromArgs = getFlagValueFromArgs(process.argv, '--profile', null);
|
||||||
const isDebugMode = !!process.argv && process.argv.indexOf('--debug') >= 0;
|
const isDebugMode = !!process.argv && process.argv.indexOf('--debug') >= 0;
|
||||||
|
const altInstanceId = getFlagValueFromArgs(process.argv, '--alt-instance-id', '');
|
||||||
|
|
||||||
// We initialize all these variables here because they are needed from the main process. They are
|
// We initialize all these variables here because they are needed from the main process. They are
|
||||||
// then passed to the renderer process via the bridge.
|
// then passed to the renderer process via the bridge.
|
||||||
const appId = `net.cozic.joplin${env === 'dev' ? 'dev' : ''}-desktop`;
|
const appId = `net.cozic.joplin${env === 'dev' ? 'dev' : ''}-desktop`;
|
||||||
let appName = env === 'dev' ? 'joplindev' : 'joplin';
|
let appName = env === 'dev' ? 'joplindev' : 'joplin';
|
||||||
if (appId.indexOf('-desktop') >= 0) appName += '-desktop';
|
if (appId.indexOf('-desktop') >= 0) appName += '-desktop';
|
||||||
const { rootProfileDir } = determineBaseAppDirs(profileFromArgs, appName);
|
const { rootProfileDir } = determineBaseAppDirs(profileFromArgs, appName, altInstanceId);
|
||||||
const settingsPath = `${rootProfileDir}/settings.json`;
|
const settingsPath = `${rootProfileDir}/settings.json`;
|
||||||
let autoUploadCrashDumps = false;
|
let autoUploadCrashDumps = false;
|
||||||
|
|
||||||
@@ -67,7 +66,7 @@ const initialCallbackUrl = process.argv.find((arg) => isCallbackUrl(arg));
|
|||||||
|
|
||||||
const wrapper = new ElectronAppWrapper(electronApp, env, rootProfileDir, isDebugMode, initialCallbackUrl);
|
const wrapper = new ElectronAppWrapper(electronApp, env, rootProfileDir, isDebugMode, initialCallbackUrl);
|
||||||
|
|
||||||
initBridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps);
|
initBridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps, altInstanceId);
|
||||||
|
|
||||||
wrapper.start().catch((error) => {
|
wrapper.start().catch((error) => {
|
||||||
console.error('Electron App fatal error:');
|
console.error('Electron App fatal error:');
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ fi
|
|||||||
|
|
||||||
if [ "$IS_DESKTOP" = "1" ]; then
|
if [ "$IS_DESKTOP" = "1" ]; then
|
||||||
cd "$ROOT_DIR/packages/app-desktop"
|
cd "$ROOT_DIR/packages/app-desktop"
|
||||||
yarn start --profile "$PROFILE_DIR"
|
yarn start --profile "$PROFILE_DIR" --alt-instance-id $USER_NUM
|
||||||
else
|
else
|
||||||
cd "$ROOT_DIR/packages/app-cli"
|
cd "$ROOT_DIR/packages/app-cli"
|
||||||
if [[ $CMD == "--" ]]; then
|
if [[ $CMD == "--" ]]; then
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default function stateToWhenClauseContext(state: AppState, options: WhenC
|
|||||||
const windowId = options?.windowId ?? defaultWindowId;
|
const windowId = options?.windowId ?? defaultWindowId;
|
||||||
const isMainWindow = windowId === defaultWindowId;
|
const isMainWindow = windowId === defaultWindowId;
|
||||||
const windowState = stateUtils.windowStateById(state, windowId);
|
const windowState = stateUtils.windowStateById(state, windowId);
|
||||||
|
const isAltInstance = !!state.settings.altInstanceId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...libStateToWhenClauseContext(state, options),
|
...libStateToWhenClauseContext(state, options),
|
||||||
@@ -26,6 +27,7 @@ export default function stateToWhenClauseContext(state: AppState, options: WhenC
|
|||||||
gotoAnythingVisible: !!state.visibleDialogs['gotoAnything'],
|
gotoAnythingVisible: !!state.visibleDialogs['gotoAnything'],
|
||||||
sidebarVisible: isMainWindow && !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
|
sidebarVisible: isMainWindow && !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
|
||||||
noteListHasNotes: !!windowState.notes.length,
|
noteListHasNotes: !!windowState.notes.length,
|
||||||
|
isAltInstance,
|
||||||
|
|
||||||
// Deprecated
|
// Deprecated
|
||||||
sideBarVisible: !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
|
sideBarVisible: !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import Setting from '@joplin/lib/models/Setting';
|
|||||||
import bridge from './bridge';
|
import bridge from './bridge';
|
||||||
|
|
||||||
|
|
||||||
export default async (linuxSafeRestart = true) => {
|
export default async () => {
|
||||||
Setting.setValue('wasClosedSuccessfully', true);
|
Setting.setValue('wasClosedSuccessfully', true);
|
||||||
await Setting.saveAll();
|
await Setting.saveAll();
|
||||||
|
|
||||||
bridge().restart(linuxSafeRestart);
|
await bridge().restart();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ const restartInSafeModeFromMain = async () => {
|
|||||||
shimInit({});
|
shimInit({});
|
||||||
|
|
||||||
const startFlags = await processStartFlags(bridge().processArgv());
|
const startFlags = await processStartFlags(bridge().processArgv());
|
||||||
const { rootProfileDir } = determineBaseAppDirs(startFlags.matched.profileDir, appName);
|
const { rootProfileDir } = determineBaseAppDirs(startFlags.matched.profileDir, appName, Setting.value('altInstanceId'));
|
||||||
const { profileDir } = await initProfile(rootProfileDir);
|
const { profileDir } = await initProfile(rootProfileDir);
|
||||||
|
|
||||||
// We can't access the database, so write to a file instead.
|
// We can't access the database, so write to a file instead.
|
||||||
const safeModeFlagFile = join(profileDir, safeModeFlagFilename);
|
const safeModeFlagFile = join(profileDir, safeModeFlagFilename);
|
||||||
await writeFile(safeModeFlagFile, 'true', 'utf8');
|
await writeFile(safeModeFlagFile, 'true', 'utf8');
|
||||||
|
|
||||||
bridge().restart();
|
await bridge().restart();
|
||||||
};
|
};
|
||||||
|
|
||||||
export default restartInSafeModeFromMain;
|
export default restartInSafeModeFromMain;
|
||||||
|
|||||||
@@ -687,7 +687,9 @@ export default class BaseApplication {
|
|||||||
// https://immerjs.github.io/immer/docs/freezing
|
// https://immerjs.github.io/immer/docs/freezing
|
||||||
setAutoFreeze(initArgs.env === 'dev');
|
setAutoFreeze(initArgs.env === 'dev');
|
||||||
|
|
||||||
const { rootProfileDir, homeDir } = determineProfileAndBaseDir(options.rootProfileDir ?? initArgs.profileDir, appName);
|
const altInstanceId = initArgs.altInstanceId || '';
|
||||||
|
|
||||||
|
const { rootProfileDir, homeDir } = determineProfileAndBaseDir(options.rootProfileDir ?? initArgs.profileDir, appName, altInstanceId);
|
||||||
const { profileDir, profileConfig, isSubProfile } = await initProfile(rootProfileDir);
|
const { profileDir, profileConfig, isSubProfile } = await initProfile(rootProfileDir);
|
||||||
this.profileConfig_ = profileConfig;
|
this.profileConfig_ = profileConfig;
|
||||||
|
|
||||||
@@ -781,6 +783,8 @@ export default class BaseApplication {
|
|||||||
Setting.setValue('isSafeMode', true);
|
Setting.setValue('isSafeMode', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Setting.setValue('altInstanceId', altInstanceId);
|
||||||
|
|
||||||
const safeModeFlagFile = join(profileDir, safeModeFlagFilename);
|
const safeModeFlagFile = join(profileDir, safeModeFlagFilename);
|
||||||
if (await fs.pathExists(safeModeFlagFile) && fs.readFileSync(safeModeFlagFile, 'utf8') === 'true') {
|
if (await fs.pathExists(safeModeFlagFile) && fs.readFileSync(safeModeFlagFile, 'utf8') === 'true') {
|
||||||
appLogger.info(`Safe mode enabled because of file: ${safeModeFlagFile}`);
|
appLogger.info(`Safe mode enabled because of file: ${safeModeFlagFile}`);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default class ClipperServer {
|
|||||||
private api_: Api = null;
|
private api_: Api = null;
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||||
private dispatch_: Function;
|
private dispatch_: Function;
|
||||||
|
private enabled_ = true;
|
||||||
|
|
||||||
private static instance_: ClipperServer = null;
|
private static instance_: ClipperServer = null;
|
||||||
|
|
||||||
@@ -40,6 +41,18 @@ export default class ClipperServer {
|
|||||||
return this.api_;
|
return this.api_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enabled() {
|
||||||
|
return this.enabled_;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEnabled(v: boolean) {
|
||||||
|
this.enabled_ = v;
|
||||||
|
|
||||||
|
if (!this.enabled_ && this.isRunning()) {
|
||||||
|
void this.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
public initialize(actionApi: any = null) {
|
public initialize(actionApi: any = null) {
|
||||||
this.api_ = new Api(() => {
|
this.api_ = new Api(() => {
|
||||||
@@ -106,6 +119,8 @@ export default class ClipperServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async start() {
|
public async start() {
|
||||||
|
if (!this.enabled()) throw new Error('Cannot start clipper server because it is disabled');
|
||||||
|
|
||||||
this.setPort(null);
|
this.setPort(null);
|
||||||
|
|
||||||
this.setStartState(StartState.Starting);
|
this.setStartState(StartState.Starting);
|
||||||
@@ -251,8 +266,11 @@ export default class ClipperServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {
|
public async stop() {
|
||||||
this.server_.destroy();
|
if (this.server_) {
|
||||||
this.server_ = null;
|
this.server_.destroy();
|
||||||
|
this.server_ = null;
|
||||||
|
}
|
||||||
|
|
||||||
this.setStartState(StartState.Idle);
|
this.setStartState(StartState.Idle);
|
||||||
this.setPort(null);
|
this.setPort(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { toSystemSlashes } from './path-utils';
|
import { toSystemSlashes } from './path-utils';
|
||||||
|
|
||||||
export default (profileFromArgs: string, appName: string) => {
|
export default (profileFromArgs: string, appName: string, altInstanceId: string) => {
|
||||||
let profileDir = '';
|
let profileDir = '';
|
||||||
let homeDir = '';
|
let homeDir = '';
|
||||||
|
|
||||||
@@ -12,7 +12,11 @@ export default (profileFromArgs: string, appName: string) => {
|
|||||||
profileDir = `${process.env.PORTABLE_EXECUTABLE_DIR}/JoplinProfile`;
|
profileDir = `${process.env.PORTABLE_EXECUTABLE_DIR}/JoplinProfile`;
|
||||||
homeDir = process.env.PORTABLE_EXECUTABLE_DIR;
|
homeDir = process.env.PORTABLE_EXECUTABLE_DIR;
|
||||||
} else {
|
} else {
|
||||||
profileDir = `${homedir()}/.config/${appName}`;
|
if (!altInstanceId) {
|
||||||
|
profileDir = `${homedir()}/.config/${appName}`;
|
||||||
|
} else {
|
||||||
|
profileDir = `${homedir()}/.config/${appName}-${altInstanceId}`;
|
||||||
|
}
|
||||||
homeDir = homedir();
|
homeDir = homedir();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
|||||||
type: SettingItemType.String,
|
type: SettingItemType.String,
|
||||||
public: false,
|
public: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'altInstanceId': {
|
||||||
|
value: '',
|
||||||
|
type: SettingItemType.String,
|
||||||
|
public: false,
|
||||||
|
appTypes: [AppType.Desktop],
|
||||||
|
storage: SettingStorage.File,
|
||||||
|
isGlobal: true,
|
||||||
|
},
|
||||||
|
|
||||||
'editor.codeView': {
|
'editor.codeView': {
|
||||||
value: true,
|
value: true,
|
||||||
type: SettingItemType.Bool,
|
type: SettingItemType.Bool,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface MatchedStartFlags {
|
|||||||
logLevel?: LogLevel;
|
logLevel?: LogLevel;
|
||||||
allowOverridingDnsResultOrder?: boolean;
|
allowOverridingDnsResultOrder?: boolean;
|
||||||
devPlugins?: string[];
|
devPlugins?: string[];
|
||||||
|
altInstanceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handles the initial flags passed to main script and
|
// Handles the initial flags passed to main script and
|
||||||
@@ -118,6 +119,12 @@ const processStartFlags = async (argv: string[], setDefaults = true) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (arg === '--alt-instance-id') {
|
||||||
|
matched.altInstanceId = nextArg;
|
||||||
|
argv.splice(0, 2);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (arg.indexOf('--remote-debugging-port=') === 0) {
|
if (arg.indexOf('--remote-debugging-port=') === 0) {
|
||||||
// Electron-specific flag used for debugging - ignore it. Electron expects this flag in '--x=y' form, a single string.
|
// Electron-specific flag used for debugging - ignore it. Electron expects this flag in '--x=y' form, a single string.
|
||||||
argv.splice(0, 1);
|
argv.splice(0, 1);
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export default function versionInfo(packageInfo: PackageInfo, plugins: Plugins)
|
|||||||
_('Sync Version: %s', Setting.value('syncVersion')),
|
_('Sync Version: %s', Setting.value('syncVersion')),
|
||||||
_('Profile Version: %s', reg.db().version()),
|
_('Profile Version: %s', reg.db().version()),
|
||||||
_('Keychain Supported: %s', keychainSupported ? _('Yes') : _('No')),
|
_('Keychain Supported: %s', keychainSupported ? _('Yes') : _('No')),
|
||||||
|
_('Alternative instance ID: %s', Setting.value('altInstanceId') || '-'),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (gitInfo) {
|
if (gitInfo) {
|
||||||
|
|||||||
@@ -174,3 +174,4 @@ Minidump
|
|||||||
collapseall
|
collapseall
|
||||||
newfolder
|
newfolder
|
||||||
unfocusable
|
unfocusable
|
||||||
|
unlocker
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ExecCommandOptions {
|
|||||||
quiet?: boolean;
|
quiet?: boolean;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
env?: Record<string, any>;
|
env?: Record<string, any>;
|
||||||
|
detached?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async (command: string | string[], options: ExecCommandOptions | null = null): Promise<string> => {
|
export default async (command: string | string[], options: ExecCommandOptions | null = null): Promise<string> => {
|
||||||
@@ -19,6 +20,7 @@ export default async (command: string | string[], options: ExecCommandOptions |
|
|||||||
showStderr: true,
|
showStderr: true,
|
||||||
quiet: false,
|
quiet: false,
|
||||||
env: {},
|
env: {},
|
||||||
|
detached: false,
|
||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ export default async (command: string | string[], options: ExecCommandOptions |
|
|||||||
const args: string[] = typeof command === 'string' ? splitCommandString(command) : command as string[];
|
const args: string[] = typeof command === 'string' ? splitCommandString(command) : command as string[];
|
||||||
const executableName = args[0];
|
const executableName = args[0];
|
||||||
args.splice(0, 1);
|
args.splice(0, 1);
|
||||||
const promise = execa(executableName, args, { env: options.env });
|
const promise = execa(executableName, args, { env: options.env, detached: options.detached });
|
||||||
if (options.showStdout && promise.stdout) promise.stdout.pipe(process.stdout);
|
if (options.showStdout && promise.stdout) promise.stdout.pipe(process.stdout);
|
||||||
if (options.showStderr && promise.stderr) promise.stderr.pipe(process.stderr);
|
if (options.showStderr && promise.stderr) promise.stderr.pipe(process.stderr);
|
||||||
const result = await promise;
|
const result = await promise;
|
||||||
|
|||||||
46
packages/utils/fs.test.ts
Normal file
46
packages/utils/fs.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { mkdirp } from 'fs-extra';
|
||||||
|
import { FileLocker } from './fs';
|
||||||
|
import { msleep, Second } from './time';
|
||||||
|
|
||||||
|
const baseTempDir = `${__dirname}/../app-cli/tests/tmp`;
|
||||||
|
|
||||||
|
const createTempDir = async () => {
|
||||||
|
const p = `${baseTempDir}/${Date.now()}`;
|
||||||
|
await mkdirp(p);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('fs', () => {
|
||||||
|
|
||||||
|
it('should lock files', async () => {
|
||||||
|
const dirPath = await createTempDir();
|
||||||
|
const filePath = `${dirPath}/test.lock`;
|
||||||
|
|
||||||
|
const locker1 = new FileLocker(filePath, {
|
||||||
|
interval: 10 * Second,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await locker1.lock()).toBe(true);
|
||||||
|
expect(await locker1.lock()).toBe(false);
|
||||||
|
|
||||||
|
locker1.unlockSync();
|
||||||
|
|
||||||
|
const locker2 = new FileLocker(filePath, {
|
||||||
|
interval: 1.5 * Second,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await locker2.lock()).toBe(true);
|
||||||
|
locker2.stopMonitoring_();
|
||||||
|
|
||||||
|
const locker3 = new FileLocker(filePath, {
|
||||||
|
interval: 1.5 * Second,
|
||||||
|
});
|
||||||
|
|
||||||
|
await msleep(2 * Second);
|
||||||
|
|
||||||
|
expect(await locker3.lock()).toBe(true);
|
||||||
|
|
||||||
|
locker3.unlockSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
|
|
||||||
import { GlobOptionsWithFileTypesFalse, sync } from 'glob';
|
import { GlobOptionsWithFileTypesFalse, sync } from 'glob';
|
||||||
|
import { stat, utimes } from 'fs/promises';
|
||||||
|
import { ensureFile, removeSync } from 'fs-extra';
|
||||||
|
import { Second } from './time';
|
||||||
|
|
||||||
// Wraps glob.sync but with good default options so that it works across
|
// Wraps glob.sync but with good default options so that it works across
|
||||||
// platforms and with consistent sorting.
|
// platforms and with consistent sorting.
|
||||||
@@ -10,3 +11,77 @@ export const globSync = (pattern: string | string[], options: GlobOptionsWithFil
|
|||||||
output.sort();
|
output.sort();
|
||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
// This is a relatively crude system for "locking" files. It does so by regularly updating the
|
||||||
|
// timestamp of a file. If the file hasn't been updated for more than x seconds, it means the lock
|
||||||
|
// is stale and the file can be considered unlocked.
|
||||||
|
//
|
||||||
|
// This is good enough for our use case, to detect if a profile is already being used by a running
|
||||||
|
// instance of Joplin.
|
||||||
|
// ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface FileLockerOptions {
|
||||||
|
interval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileLocker {
|
||||||
|
|
||||||
|
private filePath_ = '';
|
||||||
|
private interval_: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private options_: FileLockerOptions;
|
||||||
|
|
||||||
|
public constructor(filePath: string, options: FileLockerOptions|null = null) {
|
||||||
|
this.options_ = {
|
||||||
|
interval: 10 * Second,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.filePath_ = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async lock() {
|
||||||
|
if (!(await this.canLock())) return false;
|
||||||
|
|
||||||
|
await this.updateLock();
|
||||||
|
|
||||||
|
this.interval_ = setInterval(() => {
|
||||||
|
void this.updateLock();
|
||||||
|
}, this.options_.interval);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async canLock() {
|
||||||
|
try {
|
||||||
|
const s = await stat(this.filePath_);
|
||||||
|
return Date.now() - s.mtime.getTime() > (this.options_.interval as number);
|
||||||
|
} catch (error) {
|
||||||
|
const e = error as NodeJS.ErrnoException;
|
||||||
|
if (e.code === 'ENOENT') return true;
|
||||||
|
e.message = `Could not find out if this file can be locked: ${this.filePath_}`;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We want the unlock operation to be synchronous because it may be performed when the app
|
||||||
|
// is closing.
|
||||||
|
public unlockSync() {
|
||||||
|
this.stopMonitoring_();
|
||||||
|
removeSync(this.filePath_);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateLock() {
|
||||||
|
await ensureFile(this.filePath_);
|
||||||
|
const now = new Date();
|
||||||
|
await utimes(this.filePath_, now, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public stopMonitoring_() {
|
||||||
|
if (this.interval_) {
|
||||||
|
clearInterval(this.interval_);
|
||||||
|
this.interval_ = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
77
packages/utils/ipc.test.ts
Normal file
77
packages/utils/ipc.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { newHttpError, sendMessage, startServer, stopServer } from './ipc';
|
||||||
|
|
||||||
|
describe('ipc', () => {
|
||||||
|
|
||||||
|
it('should send and receive messages', async () => {
|
||||||
|
const startPort = 41168;
|
||||||
|
|
||||||
|
const server1 = await startServer(startPort, async (request) => {
|
||||||
|
if (request.action === 'testing') {
|
||||||
|
return {
|
||||||
|
text: 'hello1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw newHttpError(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
const server2 = await startServer(startPort, async (request) => {
|
||||||
|
if (request.action === 'testing') {
|
||||||
|
return {
|
||||||
|
text: 'hello2',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.action === 'ping') {
|
||||||
|
return {
|
||||||
|
text: 'pong',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw newHttpError(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
const responses = await sendMessage(startPort, {
|
||||||
|
action: 'testing',
|
||||||
|
data: {
|
||||||
|
test: 1234,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(responses).toEqual([
|
||||||
|
{ port: 41168, response: { text: 'hello1' } },
|
||||||
|
{ port: 41169, response: { text: 'hello2' } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const responses = await sendMessage(startPort, {
|
||||||
|
action: 'ping',
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(responses).toEqual([
|
||||||
|
{ port: 41169, response: { text: 'pong' } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const responses = await sendMessage(startPort, {
|
||||||
|
action: 'testing',
|
||||||
|
data: {
|
||||||
|
test: 1234,
|
||||||
|
},
|
||||||
|
sourcePort: 41168,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(responses).toEqual([
|
||||||
|
{ port: 41169, response: { text: 'hello2' } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await stopServer(server1);
|
||||||
|
await stopServer(server2);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
170
packages/utils/ipc.ts
Normal file
170
packages/utils/ipc.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { createServer, IncomingMessage, ServerResponse } from 'http';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { Server } from 'http';
|
||||||
|
import Logger from './Logger';
|
||||||
|
|
||||||
|
const tcpPortUsed = require('tcp-port-used');
|
||||||
|
const maxPorts = 10;
|
||||||
|
|
||||||
|
const findAvailablePort = async (startPort: number) => {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const port = startPort + i;
|
||||||
|
const inUse = await tcpPortUsed.check(port);
|
||||||
|
if (!inUse) return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`All potential ports are in use or not available. Starting from port: ${startPort}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findListenerPorts = async (startPort: number) => {
|
||||||
|
const output: number[] = [];
|
||||||
|
for (let i = 0; i < maxPorts; i++) {
|
||||||
|
const port = startPort + i;
|
||||||
|
const inUse = await tcpPortUsed.check(port);
|
||||||
|
if (inUse) output.push(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseJson = (req: IncomingMessage): Promise<unknown> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(body));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HttpError extends Error {
|
||||||
|
httpCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
action: string;
|
||||||
|
data: object|number|string|null;
|
||||||
|
sourcePort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response = string|number|object|boolean;
|
||||||
|
|
||||||
|
export const newHttpError = (httpCode: number, message = '') => {
|
||||||
|
const error = (new Error(message) as HttpError);
|
||||||
|
error.httpCode = httpCode;
|
||||||
|
return error;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IpcMessageHandler = (message: Message)=> Promise<Response|void>;
|
||||||
|
|
||||||
|
export interface IpcServer {
|
||||||
|
port: number;
|
||||||
|
httpServer: Server;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StartServerOptions {
|
||||||
|
logger?: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const startServer = async (startPort: number, messageHandler: IpcMessageHandler, options: StartServerOptions|null = null): Promise<IpcServer> => {
|
||||||
|
const port = await findAvailablePort(startPort);
|
||||||
|
const logger = options && options.logger ? options.logger : new Logger();
|
||||||
|
|
||||||
|
return new Promise<IpcServer>((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
try {
|
||||||
|
const message = await parseJson(req) as Message;
|
||||||
|
if (!message.action) throw newHttpError(400, 'Missing "action" property in message');
|
||||||
|
const response = await messageHandler(message);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(response));
|
||||||
|
} catch (error) {
|
||||||
|
const httpError = error as HttpError;
|
||||||
|
const httpCode = httpError.httpCode || 500;
|
||||||
|
res.writeHead(httpCode, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end(`Error ${httpCode}: ${httpError.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', error => {
|
||||||
|
if (logger) logger.error('Server error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
resolve({
|
||||||
|
httpServer: server,
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopServer = async (server: IpcServer) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.httpServer.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SendMessageOutput {
|
||||||
|
port: number;
|
||||||
|
response: Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageOptions {
|
||||||
|
logger?: Logger;
|
||||||
|
sendToSpecificPortOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendMessage = async (startPort: number, message: Message, options: SendMessageOptions|null = null) => {
|
||||||
|
const output: SendMessageOutput[] = [];
|
||||||
|
const ports = await findListenerPorts(startPort);
|
||||||
|
const logger = options && options.logger ? options.logger : new Logger();
|
||||||
|
const sendToSpecificPortOnly = !!options && !!options.sendToSpecificPortOnly;
|
||||||
|
|
||||||
|
for (const port of ports) {
|
||||||
|
if (sendToSpecificPortOnly && port !== startPort) continue;
|
||||||
|
if (message.sourcePort === port) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:${port}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// It means the server doesn't support this particular message - so just skip it
|
||||||
|
if (response.status === 404) continue;
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Request failed: on port ${port}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push({
|
||||||
|
port,
|
||||||
|
response: await response.json(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Could not send message on port ${port}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
};
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"./time": "./dist/time.js",
|
"./time": "./dist/time.js",
|
||||||
"./types": "./dist/types.js",
|
"./types": "./dist/types.js",
|
||||||
"./url": "./dist/url.js",
|
"./url": "./dist/url.js",
|
||||||
|
"./ipc": "./dist/ipc.js",
|
||||||
"./path": "./dist/path.js"
|
"./path": "./dist/path.js"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
@@ -42,7 +43,8 @@
|
|||||||
"markdown-it": "13.0.2",
|
"markdown-it": "13.0.2",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"node-fetch": "2.6.7",
|
"node-fetch": "2.6.7",
|
||||||
"sprintf-js": "1.1.3"
|
"sprintf-js": "1.1.3",
|
||||||
|
"tcp-port-used": "1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/fs-extra": "11.0.4",
|
"@types/fs-extra": "11.0.4",
|
||||||
|
|||||||
36
readme/apps/multiple_instances.md
Normal file
36
readme/apps/multiple_instances.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Running multiple instances of Joplin
|
||||||
|
|
||||||
|
Joplin Desktop offers the capability to run **multiple instances** of the application simultaneously. Each instance is a separate application with its own configuration, plugins, and settings, meaning changes made in one instance do not affect the other. This feature is particularly useful for users who wish to maintain a clear separation between work and personal notes or utilise Joplin in multi-desktop environments.
|
||||||
|
|
||||||
|
## Key Features of Multiple Instances
|
||||||
|
|
||||||
|
1. **Independent Applications**:
|
||||||
|
|
||||||
|
Each instance is completely isolated, operating as a standalone version of Joplin. This ensures no overlap in settings, plugins, or notes between instances.
|
||||||
|
|
||||||
|
2. **Use Case Scenarios**:
|
||||||
|
|
||||||
|
- Maintain separate environments for work and personal notes.
|
||||||
|
- Use Joplin on multi-desktop setups, with an instance on each virtual desktop.
|
||||||
|
|
||||||
|
## Supported Number of Instances
|
||||||
|
|
||||||
|
Currently, Joplin supports up to **two running instances**:
|
||||||
|
|
||||||
|
1. **Main Instance**: The primary application instance, with full access to all Joplin features.
|
||||||
|
|
||||||
|
2. **Alternative Instance**: A secondary application instance that functions independently. However, it does not support the **Web Clipper service**, which can only run in the main instance.
|
||||||
|
|
||||||
|
## How to Launch a Second Instance
|
||||||
|
|
||||||
|
To start a second instance of Joplin:
|
||||||
|
|
||||||
|
1. Open the main Joplin application.
|
||||||
|
|
||||||
|
2. Navigate to the menu and select:
|
||||||
|
|
||||||
|
**File** => **New application instance...**
|
||||||
|
|
||||||
|
3. A new instance of Joplin will open with its own profile.
|
||||||
|
|
||||||
|
This second instance operates independently, allowing you to customise it as needed.
|
||||||
25
readme/dev/spec/multiple_instances.md
Normal file
25
readme/dev/spec/multiple_instances.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Multiple instance support
|
||||||
|
|
||||||
|
Joplin Desktop supports multiple instances through **profile locking** and **IPC messaging**.
|
||||||
|
|
||||||
|
### Profile Locking
|
||||||
|
|
||||||
|
- A lock file is updated every `x` seconds by each instance.
|
||||||
|
|
||||||
|
- If a valid lock exists, the new instance sends a message to all running instances and closes. This message prompts the other active instance to move to the front.
|
||||||
|
|
||||||
|
### IPC Messaging
|
||||||
|
|
||||||
|
- IPC is implemented using lightweight HTTP servers in each instance, communicating via `POST` requests.
|
||||||
|
|
||||||
|
- When a message is sent, the implementation automatically discovers running IPC servers.
|
||||||
|
|
||||||
|
### Instance Differentiation
|
||||||
|
|
||||||
|
- The `--alt-instance-id` flag must be used to launch an alternative instance. This disables services like the Web Clipper.
|
||||||
|
|
||||||
|
- The `altInstanceId` setting is consulted by the application to determine its type (main or alternative instance).
|
||||||
|
|
||||||
|
### Current Limitations
|
||||||
|
|
||||||
|
The system is designed to handle multiple instances but currently supports only two through the UI: a **main instance** and an **alternative instance**.
|
||||||
Reference in New Issue
Block a user