mirror of
https://github.com/laurent22/joplin.git
synced 2025-04-11 11:12:03 +02:00
Desktop: Add support for multiple instances (#11963)
This commit is contained in:
parent
7b2b3a4f80
commit
cb5ffd968d
@ -158,6 +158,7 @@ packages/app-desktop/commands/exportFolders.js
|
||||
packages/app-desktop/commands/exportNotes.js
|
||||
packages/app-desktop/commands/focusElement.js
|
||||
packages/app-desktop/commands/index.js
|
||||
packages/app-desktop/commands/newAppInstance.js
|
||||
packages/app-desktop/commands/openNoteInNewWindow.js
|
||||
packages/app-desktop/commands/openProfileDirectory.js
|
||||
packages/app-desktop/commands/replaceMisspelling.js
|
||||
|
@ -57,6 +57,8 @@ module.exports = {
|
||||
'tinymce': 'readonly',
|
||||
|
||||
'JSX': 'readonly',
|
||||
|
||||
'NodeJS': 'readonly',
|
||||
},
|
||||
'parserOptions': {
|
||||
'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/focusElement.js
|
||||
packages/app-desktop/commands/index.js
|
||||
packages/app-desktop/commands/newAppInstance.js
|
||||
packages/app-desktop/commands/openNoteInNewWindow.js
|
||||
packages/app-desktop/commands/openProfileDirectory.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 AutoUpdaterService, { defaultUpdateInterval, initialUpdateStartup } from './services/autoUpdater/AutoUpdaterService';
|
||||
import type ShimType from '@joplin/lib/shim';
|
||||
const shim: typeof ShimType = require('@joplin/lib/shim').default;
|
||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
|
||||
import { BrowserWindow, Tray, WebContents, screen } from 'electron';
|
||||
import { FileLocker } from '@joplin/utils/fs';
|
||||
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';
|
||||
const url = require('url');
|
||||
const path = require('path');
|
||||
@ -19,6 +20,7 @@ import handleCustomProtocols, { CustomProtocolHandler } from './utils/customProt
|
||||
import { clearTimeout, setTimeout } from 'timers';
|
||||
import { resolve } from 'path';
|
||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
import { msleep } from '@joplin/utils/time';
|
||||
|
||||
interface RendererProcessQuitReply {
|
||||
canClose: boolean;
|
||||
@ -36,8 +38,7 @@ interface SecondaryWindowData {
|
||||
|
||||
export default class ElectronAppWrapper {
|
||||
private logger_: Logger = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private electronApp_: any;
|
||||
private electronApp_: App;
|
||||
private env_: string;
|
||||
private isDebugMode_: boolean;
|
||||
private profilePath_: string;
|
||||
@ -58,13 +59,28 @@ export default class ElectronAppWrapper {
|
||||
private customProtocolHandler_: CustomProtocolHandler = null;
|
||||
private updatePollInterval_: ReturnType<typeof setTimeout>|null = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public constructor(electronApp: any, env: string, profilePath: string|null, isDebugMode: boolean, initialCallbackUrl: string) {
|
||||
private profileLocker_: FileLocker|null = null;
|
||||
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.env_ = env;
|
||||
this.isDebugMode_ = isDebugMode;
|
||||
this.profilePath_ = profilePath;
|
||||
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() {
|
||||
@ -410,7 +426,7 @@ export default class ElectronAppWrapper {
|
||||
if (message.target === 'plugin') {
|
||||
const win = this.pluginWindows_[message.pluginId];
|
||||
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;
|
||||
}
|
||||
|
||||
@ -465,12 +481,24 @@ export default class ElectronAppWrapper {
|
||||
});
|
||||
}
|
||||
|
||||
public quit() {
|
||||
private onExit() {
|
||||
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();
|
||||
}
|
||||
|
||||
public exit(errorCode = 0) {
|
||||
this.onExit();
|
||||
this.electronApp_.exit(errorCode);
|
||||
}
|
||||
|
||||
@ -536,20 +564,26 @@ export default class ElectronAppWrapper {
|
||||
this.tray_ = null;
|
||||
}
|
||||
|
||||
public ensureSingleInstance() {
|
||||
if (this.env_ === 'dev') return false;
|
||||
public async sendCrossAppIpcMessage(message: Message, port: number|null = null, options: SendMessageOptions = null) {
|
||||
this.ipcLogger_.info('Sending message:', message);
|
||||
|
||||
const gotTheLock = this.electronApp_.requestSingleInstanceLock();
|
||||
if (port === null) port = this.ipcStartPort_;
|
||||
|
||||
if (!gotTheLock) {
|
||||
// Another instance is already running - exit
|
||||
this.quit();
|
||||
return true;
|
||||
return await sendMessage(port, { ...message, sourcePort: this.ipcServer_.port }, {
|
||||
logger: this.ipcLogger_,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
// 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 activateWindow = (argv: string[]) => {
|
||||
const win = this.mainWindow();
|
||||
if (!win) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
@ -562,9 +596,85 @@ export default class ElectronAppWrapper {
|
||||
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) {
|
||||
@ -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.
|
||||
await this.waitForElectronAppReady();
|
||||
|
||||
const alreadyRunning = this.ensureSingleInstance();
|
||||
const alreadyRunning = await this.ensureSingleInstance();
|
||||
if (alreadyRunning) return;
|
||||
|
||||
this.createWindow();
|
||||
|
@ -617,10 +617,11 @@ class Application extends BaseApplication {
|
||||
clipperLogger.addTarget(TargetType.Console);
|
||||
|
||||
ClipperServer.instance().initialize(actionApi);
|
||||
ClipperServer.instance().setEnabled(!Setting.value('altInstanceId'));
|
||||
ClipperServer.instance().setLogger(clipperLogger);
|
||||
ClipperServer.instance().setDispatch(this.store().dispatch);
|
||||
|
||||
if (Setting.value('clipperServer.autoStart')) {
|
||||
if (ClipperServer.instance().enabled() && Setting.value('clipperServer.autoStart')) {
|
||||
void ClipperServer.instance().start();
|
||||
}
|
||||
|
||||
|
@ -15,6 +15,7 @@ import isSafeToOpen from './utils/isSafeToOpen';
|
||||
import { closeSync, openSync, readSync, statSync } from 'fs';
|
||||
import { KB } from '@joplin/utils/bytes';
|
||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
import { execCommand } from '@joplin/utils';
|
||||
|
||||
interface LastSelectedPath {
|
||||
file: string;
|
||||
@ -43,16 +44,18 @@ export class Bridge {
|
||||
private appName_: string;
|
||||
private appId_: string;
|
||||
private logFilePath_ = '';
|
||||
private altInstanceId_ = '';
|
||||
|
||||
private extraAllowedExtensions_: string[] = [];
|
||||
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.appId_ = appId;
|
||||
this.appName_ = appName;
|
||||
this.rootProfileDir_ = rootProfileDir;
|
||||
this.autoUploadCrashDumps_ = autoUploadCrashDumps;
|
||||
this.altInstanceId_ = altInstanceId;
|
||||
this.lastSelectedPaths_ = {
|
||||
file: null,
|
||||
directory: null,
|
||||
@ -218,6 +221,10 @@ export class Bridge {
|
||||
return this.electronApp().electronApp().getLocale();
|
||||
};
|
||||
|
||||
public altInstanceId() {
|
||||
return this.altInstanceId_;
|
||||
}
|
||||
|
||||
// Applies to electron-context-menu@3:
|
||||
//
|
||||
// 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
|
||||
// to notify services and component that the app is about to close
|
||||
// but for the current use-case it's not really needed.
|
||||
@ -502,8 +540,34 @@ export class Bridge {
|
||||
execPath: process.env.PORTABLE_EXECUTABLE_FILE,
|
||||
};
|
||||
app.relaunch(options);
|
||||
} else if (shim.isLinux() && linuxSafeRestart) {
|
||||
this.showInfoMessageBox(_('The app is now going to close. Please relaunch it to complete the process.'));
|
||||
} else if (this.altInstanceId_) {
|
||||
// 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 {
|
||||
app.relaunch();
|
||||
}
|
||||
@ -534,9 +598,9 @@ export class Bridge {
|
||||
|
||||
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');
|
||||
bridge_ = new Bridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps);
|
||||
bridge_ = new Bridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps, altInstanceId);
|
||||
return bridge_;
|
||||
}
|
||||
|
||||
|
@ -6,6 +6,7 @@ import * as exportDeletionLog from './exportDeletionLog';
|
||||
import * as exportFolders from './exportFolders';
|
||||
import * as exportNotes from './exportNotes';
|
||||
import * as focusElement from './focusElement';
|
||||
import * as newAppInstance from './newAppInstance';
|
||||
import * as openNoteInNewWindow from './openNoteInNewWindow';
|
||||
import * as openProfileDirectory from './openProfileDirectory';
|
||||
import * as replaceMisspelling from './replaceMisspelling';
|
||||
@ -28,6 +29,7 @@ const index: any[] = [
|
||||
exportFolders,
|
||||
exportNotes,
|
||||
focusElement,
|
||||
newAppInstance,
|
||||
openNoteInNewWindow,
|
||||
openProfileDirectory,
|
||||
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() {
|
||||
if (!ClipperServer.instance().enabled()) return;
|
||||
Setting.setValue('clipperServer.autoStart', true);
|
||||
void ClipperServer.instance().start();
|
||||
}
|
||||
@ -70,6 +71,8 @@ class ClipperConfigScreenComponent extends React.Component {
|
||||
|
||||
const webClipperStatusComps = [];
|
||||
|
||||
const clipperEnabled = ClipperServer.instance().enabled();
|
||||
|
||||
if (this.props.clipperServerAutoStart) {
|
||||
webClipperStatusComps.push(
|
||||
<p key="text_1" style={theme.textStyle}>
|
||||
@ -95,13 +98,22 @@ class ClipperConfigScreenComponent extends React.Component {
|
||||
</button>,
|
||||
);
|
||||
} 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(
|
||||
<p key="text_4" style={theme.textStyle}>
|
||||
{_('The web clipper service is not enabled.')}
|
||||
</p>,
|
||||
);
|
||||
webClipperStatusComps.push(
|
||||
<button key="enable_button" style={buttonStyle} onClick={this.enableClipperServer_click}>
|
||||
<button key="enable_button" style={buttonStyle} onClick={this.enableClipperServer_click} disabled={!clipperEnabled}>
|
||||
{_('Enable Web Clipper Service')}
|
||||
</button>,
|
||||
);
|
||||
|
@ -555,6 +555,7 @@ function useMenu(props: Props) {
|
||||
const newFolderItem = menuItemDic.newFolder;
|
||||
const newSubFolderItem = menuItemDic.newSubFolder;
|
||||
const printItem = menuItemDic.print;
|
||||
const newAppInstance = menuItemDic.newAppInstance;
|
||||
const switchProfileItem = {
|
||||
label: _('Switch profile'),
|
||||
submenu: switchProfileMenuItems,
|
||||
@ -718,8 +719,11 @@ function useMenu(props: Props) {
|
||||
}, {
|
||||
type: 'separator',
|
||||
},
|
||||
printItem,
|
||||
printItem, {
|
||||
type: 'separator',
|
||||
},
|
||||
switchProfileItem,
|
||||
newAppInstance,
|
||||
],
|
||||
};
|
||||
|
||||
|
@ -48,6 +48,7 @@ export default function() {
|
||||
'toggleTabMovesFocus',
|
||||
'editor.deleteLine',
|
||||
'editor.duplicateLine',
|
||||
'newAppInstance',
|
||||
// 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
|
||||
// undo/redo in regular text fields.
|
||||
|
@ -25,28 +25,27 @@ process.on('unhandledRejection', (reason, p) => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Likewise, we want to know if a profile is specified early, in particular
|
||||
// to save the window state data.
|
||||
function getProfileFromArgs(args) {
|
||||
const getFlagValueFromArgs = (args, flag, defaultValue) => {
|
||||
if (!args) return null;
|
||||
const profileIndex = args.indexOf('--profile');
|
||||
if (profileIndex <= 0 || profileIndex >= args.length - 1) return null;
|
||||
const profileValue = args[profileIndex + 1];
|
||||
return profileValue ? profileValue : null;
|
||||
}
|
||||
const index = args.indexOf(flag);
|
||||
if (index <= 0 || index >= args.length - 1) return defaultValue;
|
||||
const value = args[index + 1];
|
||||
return value ? value : defaultValue;
|
||||
};
|
||||
|
||||
Logger.fsDriver_ = new FsDriverNode();
|
||||
|
||||
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 altInstanceId = getFlagValueFromArgs(process.argv, '--alt-instance-id', '');
|
||||
|
||||
// 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.
|
||||
const appId = `net.cozic.joplin${env === 'dev' ? 'dev' : ''}-desktop`;
|
||||
let appName = env === 'dev' ? 'joplindev' : 'joplin';
|
||||
if (appId.indexOf('-desktop') >= 0) appName += '-desktop';
|
||||
const { rootProfileDir } = determineBaseAppDirs(profileFromArgs, appName);
|
||||
const { rootProfileDir } = determineBaseAppDirs(profileFromArgs, appName, altInstanceId);
|
||||
const settingsPath = `${rootProfileDir}/settings.json`;
|
||||
let autoUploadCrashDumps = false;
|
||||
|
||||
@ -67,7 +66,7 @@ const initialCallbackUrl = process.argv.find((arg) => isCallbackUrl(arg));
|
||||
|
||||
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) => {
|
||||
console.error('Electron App fatal error:');
|
||||
|
@ -180,7 +180,7 @@ fi
|
||||
|
||||
if [ "$IS_DESKTOP" = "1" ]; then
|
||||
cd "$ROOT_DIR/packages/app-desktop"
|
||||
yarn start --profile "$PROFILE_DIR"
|
||||
yarn start --profile "$PROFILE_DIR" --alt-instance-id $USER_NUM
|
||||
else
|
||||
cd "$ROOT_DIR/packages/app-cli"
|
||||
if [[ $CMD == "--" ]]; then
|
||||
|
@ -12,6 +12,7 @@ export default function stateToWhenClauseContext(state: AppState, options: WhenC
|
||||
const windowId = options?.windowId ?? defaultWindowId;
|
||||
const isMainWindow = windowId === defaultWindowId;
|
||||
const windowState = stateUtils.windowStateById(state, windowId);
|
||||
const isAltInstance = !!state.settings.altInstanceId;
|
||||
|
||||
return {
|
||||
...libStateToWhenClauseContext(state, options),
|
||||
@ -26,6 +27,7 @@ export default function stateToWhenClauseContext(state: AppState, options: WhenC
|
||||
gotoAnythingVisible: !!state.visibleDialogs['gotoAnything'],
|
||||
sidebarVisible: isMainWindow && !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
|
||||
noteListHasNotes: !!windowState.notes.length,
|
||||
isAltInstance,
|
||||
|
||||
// Deprecated
|
||||
sideBarVisible: !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
|
||||
|
@ -2,9 +2,9 @@ import Setting from '@joplin/lib/models/Setting';
|
||||
import bridge from './bridge';
|
||||
|
||||
|
||||
export default async (linuxSafeRestart = true) => {
|
||||
export default async () => {
|
||||
Setting.setValue('wasClosedSuccessfully', true);
|
||||
await Setting.saveAll();
|
||||
|
||||
bridge().restart(linuxSafeRestart);
|
||||
await bridge().restart();
|
||||
};
|
||||
|
@ -21,14 +21,14 @@ const restartInSafeModeFromMain = async () => {
|
||||
shimInit({});
|
||||
|
||||
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);
|
||||
|
||||
// We can't access the database, so write to a file instead.
|
||||
const safeModeFlagFile = join(profileDir, safeModeFlagFilename);
|
||||
await writeFile(safeModeFlagFile, 'true', 'utf8');
|
||||
|
||||
bridge().restart();
|
||||
await bridge().restart();
|
||||
};
|
||||
|
||||
export default restartInSafeModeFromMain;
|
||||
|
@ -687,7 +687,9 @@ export default class BaseApplication {
|
||||
// https://immerjs.github.io/immer/docs/freezing
|
||||
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);
|
||||
this.profileConfig_ = profileConfig;
|
||||
|
||||
@ -781,6 +783,8 @@ export default class BaseApplication {
|
||||
Setting.setValue('isSafeMode', true);
|
||||
}
|
||||
|
||||
Setting.setValue('altInstanceId', altInstanceId);
|
||||
|
||||
const safeModeFlagFile = join(profileDir, safeModeFlagFilename);
|
||||
if (await fs.pathExists(safeModeFlagFile) && fs.readFileSync(safeModeFlagFile, 'utf8') === 'true') {
|
||||
appLogger.info(`Safe mode enabled because of file: ${safeModeFlagFile}`);
|
||||
|
@ -23,6 +23,7 @@ export default class ClipperServer {
|
||||
private api_: Api = null;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
private dispatch_: Function;
|
||||
private enabled_ = true;
|
||||
|
||||
private static instance_: ClipperServer = null;
|
||||
|
||||
@ -40,6 +41,18 @@ export default class ClipperServer {
|
||||
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
|
||||
public initialize(actionApi: any = null) {
|
||||
this.api_ = new Api(() => {
|
||||
@ -106,6 +119,8 @@ export default class ClipperServer {
|
||||
}
|
||||
|
||||
public async start() {
|
||||
if (!this.enabled()) throw new Error('Cannot start clipper server because it is disabled');
|
||||
|
||||
this.setPort(null);
|
||||
|
||||
this.setStartState(StartState.Starting);
|
||||
@ -251,8 +266,11 @@ export default class ClipperServer {
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
this.server_.destroy();
|
||||
this.server_ = null;
|
||||
if (this.server_) {
|
||||
this.server_.destroy();
|
||||
this.server_ = null;
|
||||
}
|
||||
|
||||
this.setStartState(StartState.Idle);
|
||||
this.setPort(null);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { homedir } from 'os';
|
||||
import { toSystemSlashes } from './path-utils';
|
||||
|
||||
export default (profileFromArgs: string, appName: string) => {
|
||||
export default (profileFromArgs: string, appName: string, altInstanceId: string) => {
|
||||
let profileDir = '';
|
||||
let homeDir = '';
|
||||
|
||||
@ -12,7 +12,11 @@ export default (profileFromArgs: string, appName: string) => {
|
||||
profileDir = `${process.env.PORTABLE_EXECUTABLE_DIR}/JoplinProfile`;
|
||||
homeDir = process.env.PORTABLE_EXECUTABLE_DIR;
|
||||
} else {
|
||||
profileDir = `${homedir()}/.config/${appName}`;
|
||||
if (!altInstanceId) {
|
||||
profileDir = `${homedir()}/.config/${appName}`;
|
||||
} else {
|
||||
profileDir = `${homedir()}/.config/${appName}-${altInstanceId}`;
|
||||
}
|
||||
homeDir = homedir();
|
||||
}
|
||||
|
||||
|
@ -62,6 +62,16 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
type: SettingItemType.String,
|
||||
public: false,
|
||||
},
|
||||
|
||||
'altInstanceId': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
public: false,
|
||||
appTypes: [AppType.Desktop],
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'editor.codeView': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
|
@ -13,6 +13,7 @@ export interface MatchedStartFlags {
|
||||
logLevel?: LogLevel;
|
||||
allowOverridingDnsResultOrder?: boolean;
|
||||
devPlugins?: string[];
|
||||
altInstanceId?: string;
|
||||
}
|
||||
|
||||
// Handles the initial flags passed to main script and
|
||||
@ -118,6 +119,12 @@ const processStartFlags = async (argv: string[], setDefaults = true) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--alt-instance-id') {
|
||||
matched.altInstanceId = nextArg;
|
||||
argv.splice(0, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
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.
|
||||
argv.splice(0, 1);
|
||||
|
@ -89,6 +89,7 @@ export default function versionInfo(packageInfo: PackageInfo, plugins: Plugins)
|
||||
_('Sync Version: %s', Setting.value('syncVersion')),
|
||||
_('Profile Version: %s', reg.db().version()),
|
||||
_('Keychain Supported: %s', keychainSupported ? _('Yes') : _('No')),
|
||||
_('Alternative instance ID: %s', Setting.value('altInstanceId') || '-'),
|
||||
];
|
||||
|
||||
if (gitInfo) {
|
||||
|
@ -174,3 +174,4 @@ Minidump
|
||||
collapseall
|
||||
newfolder
|
||||
unfocusable
|
||||
unlocker
|
||||
|
@ -10,6 +10,7 @@ interface ExecCommandOptions {
|
||||
quiet?: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
env?: Record<string, any>;
|
||||
detached?: boolean;
|
||||
}
|
||||
|
||||
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,
|
||||
quiet: false,
|
||||
env: {},
|
||||
detached: false,
|
||||
...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 executableName = args[0];
|
||||
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.showStderr && promise.stderr) promise.stderr.pipe(process.stderr);
|
||||
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 { 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
|
||||
// platforms and with consistent sorting.
|
||||
@ -10,3 +11,77 @@ export const globSync = (pattern: string | string[], options: GlobOptionsWithFil
|
||||
output.sort();
|
||||
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",
|
||||
"./types": "./dist/types.js",
|
||||
"./url": "./dist/url.js",
|
||||
"./ipc": "./dist/ipc.js",
|
||||
"./path": "./dist/path.js"
|
||||
},
|
||||
"publishConfig": {
|
||||
@ -42,7 +43,8 @@
|
||||
"markdown-it": "13.0.2",
|
||||
"moment": "2.30.1",
|
||||
"node-fetch": "2.6.7",
|
||||
"sprintf-js": "1.1.3"
|
||||
"sprintf-js": "1.1.3",
|
||||
"tcp-port-used": "1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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**.
|
Loading…
x
Reference in New Issue
Block a user