diff --git a/.eslintignore b/.eslintignore index 74f1f559b..ba423091e 100644 --- a/.eslintignore +++ b/.eslintignore @@ -864,6 +864,9 @@ packages/lib/Logger.js.map packages/lib/PoorManIntervals.d.ts packages/lib/PoorManIntervals.js packages/lib/PoorManIntervals.js.map +packages/lib/ProtocolUtils.d.ts +packages/lib/ProtocolUtils.js +packages/lib/ProtocolUtils.js.map packages/lib/SyncTargetJoplinCloud.d.ts packages/lib/SyncTargetJoplinCloud.js packages/lib/SyncTargetJoplinCloud.js.map diff --git a/.gitignore b/.gitignore index 07fe7e47f..162f1a14f 100644 --- a/.gitignore +++ b/.gitignore @@ -850,6 +850,9 @@ packages/lib/Logger.js.map packages/lib/PoorManIntervals.d.ts packages/lib/PoorManIntervals.js packages/lib/PoorManIntervals.js.map +packages/lib/ProtocolUtils.d.ts +packages/lib/ProtocolUtils.js +packages/lib/ProtocolUtils.js.map packages/lib/SyncTargetJoplinCloud.d.ts packages/lib/SyncTargetJoplinCloud.js packages/lib/SyncTargetJoplinCloud.js.map diff --git a/packages/app-desktop/ElectronAppWrapper.ts b/packages/app-desktop/ElectronAppWrapper.ts index cbb26217b..61939a44e 100644 --- a/packages/app-desktop/ElectronAppWrapper.ts +++ b/packages/app-desktop/ElectronAppWrapper.ts @@ -1,6 +1,7 @@ import Logger from '@joplin/lib/Logger'; import { PluginMessage } from './services/plugins/PluginRunner'; import shim from '@joplin/lib/shim'; +import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils'; const { BrowserWindow, Tray, screen } = require('electron'); const url = require('url'); @@ -30,12 +31,14 @@ export default class ElectronAppWrapper { private buildDir_: string = null; private rendererProcessQuitReply_: RendererProcessQuitReply = null; private pluginWindows_: PluginWindows = {}; + private initialCallbackUrl_: string = null; - constructor(electronApp: any, env: string, profilePath: string, isDebugMode: boolean) { + constructor(electronApp: any, env: string, profilePath: string, isDebugMode: boolean, initialCallbackUrl: string) { this.electronApp_ = electronApp; this.env_ = env; this.isDebugMode_ = isDebugMode; this.profilePath_ = profilePath; + this.initialCallbackUrl_ = initialCallbackUrl; } electronApp() { @@ -58,6 +61,10 @@ export default class ElectronAppWrapper { return this.env_; } + initialCallbackUrl() { + return this.initialCallbackUrl_; + } + createWindow() { // Set to true to view errors if the application does not start const debugEarlyBugs = this.env_ === 'dev' || this.isDebugMode_; @@ -320,12 +327,18 @@ export default class ElectronAppWrapper { } // Someone tried to open a second instance - focus our window instead - this.electronApp_.on('second-instance', () => { + this.electronApp_.on('second-instance', (_e: any, argv: string[]) => { const win = this.window(); if (!win) return; if (win.isMinimized()) win.restore(); win.show(); win.focus(); + if (process.platform !== 'darwin') { + const url = argv.find((arg) => isCallbackUrl(arg)); + if (url) { + void this.openCallbackUrl(url); + } + } }); return false; @@ -352,6 +365,16 @@ export default class ElectronAppWrapper { this.electronApp_.on('activate', () => { this.win_.show(); }); + + this.electronApp_.on('open-url', (_event: any, url: string) => { + void this.openCallbackUrl(url); + }); + } + + async openCallbackUrl(url: string) { + this.win_.webContents.send('asynchronous-message', 'openCallbackUrl', { + url: url, + }); } } diff --git a/packages/app-desktop/gui/MainScreen/MainScreen.tsx b/packages/app-desktop/gui/MainScreen/MainScreen.tsx index ed3a60f22..ed4fa051d 100644 --- a/packages/app-desktop/gui/MainScreen/MainScreen.tsx +++ b/packages/app-desktop/gui/MainScreen/MainScreen.tsx @@ -36,6 +36,8 @@ import ShareService from '@joplin/lib/services/share/ShareService'; import { reg } from '@joplin/lib/registry'; import removeKeylessItems from '../ResizableLayout/utils/removeKeylessItems'; import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils'; +import { parseCallbackUrl } from '@joplin/lib/callbackUrlUtils'; +import ElectronAppWrapper from '../../ElectronAppWrapper'; import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils'; const { connect } = require('react-redux'); @@ -187,6 +189,23 @@ class MainScreenComponent extends React.Component { this.layoutModeListenerKeyDown = this.layoutModeListenerKeyDown.bind(this); window.addEventListener('resize', this.window_resize); + + ipcRenderer.on('asynchronous-message', (_event: any, message: string, args: any) => { + if (message === 'openCallbackUrl') { + this.openCallbackUrl(args.url); + } + }); + + const initialCallbackUrl = (bridge().electronApp() as ElectronAppWrapper).initialCallbackUrl(); + if (initialCallbackUrl) { + this.openCallbackUrl(initialCallbackUrl); + } + } + + private openCallbackUrl(url: string) { + console.log(`openUrl ${url}`); + const { command, params } = parseCallbackUrl(url); + void CommandService.instance().execute(command.toString(), params.id); } private updateLayoutPluginViews(layout: LayoutItem, plugins: PluginStates) { diff --git a/packages/app-desktop/gui/Sidebar/Sidebar.tsx b/packages/app-desktop/gui/Sidebar/Sidebar.tsx index 5b5eee00e..314944cdb 100644 --- a/packages/app-desktop/gui/Sidebar/Sidebar.tsx +++ b/packages/app-desktop/gui/Sidebar/Sidebar.tsx @@ -20,6 +20,7 @@ import Logger from '@joplin/lib/Logger'; import { FolderEntity } from '@joplin/lib/services/database/types'; import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext'; import { store } from '@joplin/lib/reducer'; +import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils'; const { connect } = require('react-redux'); const shared = require('@joplin/lib/components/shared/side-menu-shared.js'); const { themeStyle } = require('@joplin/lib/theme'); @@ -28,6 +29,7 @@ const Menu = bridge().Menu; const MenuItem = bridge().MenuItem; const { substrWithEllipsis } = require('@joplin/lib/string-utils'); const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids'); +const { clipboard } = require('electron'); const logger = Logger.create('Sidebar'); @@ -326,10 +328,29 @@ class SidebarComponent extends React.Component { ); } + if (itemType === BaseModel.TYPE_FOLDER) { + menu.append( + new MenuItem({ + label: _('Copy external link'), + click: () => { + clipboard.writeText(getFolderCallbackUrl(itemId)); + }, + }) + ); + } + if (itemType === BaseModel.TYPE_TAG) { menu.append(new MenuItem( menuUtils.commandToStatefulMenuItem('renameTag', itemId) )); + menu.append( + new MenuItem({ + label: _('Copy external link'), + click: () => { + clipboard.writeText(getTagCallbackUrl(itemId)); + }, + }) + ); } const pluginViews = pluginUtils.viewsByType(this.pluginsRef.current, 'menuItem'); diff --git a/packages/app-desktop/gui/utils/NoteListUtils.ts b/packages/app-desktop/gui/utils/NoteListUtils.ts index 81c9dbfea..666955948 100644 --- a/packages/app-desktop/gui/utils/NoteListUtils.ts +++ b/packages/app-desktop/gui/utils/NoteListUtils.ts @@ -6,6 +6,7 @@ import MenuUtils from '@joplin/lib/services/commands/MenuUtils'; import InteropServiceHelper from '../../InteropServiceHelper'; import { _ } from '@joplin/lib/locale'; import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types'; +import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils'; import BaseModel from '@joplin/lib/BaseModel'; const bridge = require('electron').remote.require('./bridge').default; @@ -14,6 +15,7 @@ const MenuItem = bridge().MenuItem; import Note from '@joplin/lib/models/Note'; import Setting from '@joplin/lib/models/Setting'; const { substrWithEllipsis } = require('@joplin/lib/string-utils'); +const { clipboard } = require('electron'); interface ContextMenuProps { notes: any[]; @@ -122,7 +124,6 @@ export default class NoteListUtils { new MenuItem({ label: _('Copy Markdown link'), click: async () => { - const { clipboard } = require('electron'); const links = []; for (let i = 0; i < noteIds.length; i++) { const note = await Note.load(noteIds[i]); @@ -133,6 +134,17 @@ export default class NoteListUtils { }) ); + if (noteIds.length == 1) { + menu.append( + new MenuItem({ + label: _('Copy external link'), + click: () => { + clipboard.writeText(getNoteCallbackUrl(noteIds[0])); + }, + }) + ); + } + if ([9, 10].includes(Setting.value('sync.target'))) { menu.append( new MenuItem( diff --git a/packages/app-desktop/main.js b/packages/app-desktop/main.js index e0d4159dc..2d8dc44a8 100644 --- a/packages/app-desktop/main.js +++ b/packages/app-desktop/main.js @@ -7,6 +7,7 @@ const Logger = require('@joplin/lib/Logger').default; const FsDriverNode = require('@joplin/lib/fs-driver-node').default; const envFromArgs = require('@joplin/lib/envFromArgs'); const packageInfo = require('./packageInfo.js'); +const { isCallbackUrl } = require('@joplin/lib/ProtocolUtils'); // Electron takes the application name from package.json `name` and // displays this in the tray icon toolip and message box titles, however in @@ -36,7 +37,11 @@ const env = envFromArgs(process.argv); const profilePath = profileFromArgs(process.argv); const isDebugMode = !!process.argv && process.argv.indexOf('--debug') >= 0; -const wrapper = new ElectronAppWrapper(electronApp, env, profilePath, isDebugMode); +electronApp.setAsDefaultProtocolClient('joplin'); + +const initialCallbackUrl = process.argv.find((arg) => isCallbackUrl(arg)); + +const wrapper = new ElectronAppWrapper(electronApp, env, profilePath, isDebugMode, initialCallbackUrl); initBridge(wrapper); diff --git a/packages/app-desktop/package.json b/packages/app-desktop/package.json index 4c8af0800..008456a99 100644 --- a/packages/app-desktop/package.json +++ b/packages/app-desktop/package.json @@ -77,13 +77,22 @@ "icon": "../../Assets/macOs.icns", "target": "dmg", "hardenedRuntime": true, - "entitlements": "./build-mac/entitlements.mac.inherit.plist" + "entitlements": "./build-mac/entitlements.mac.inherit.plist", + "extendInfo": { + "CFBundleURLTypes": [ + { + "CFBundleURLSchemes": ["joplin"], + "CFBundleTypeRole": "Editor" + } + ] + } }, "linux": { "icon": "../../Assets/LinuxIcons", "category": "Office", "desktop": { - "Icon": "joplin" + "Icon": "joplin", + "MimeType": "x-scheme-handler/joplin;" }, "target": "AppImage" }, diff --git a/packages/lib/callbackUrlUtils.ts b/packages/lib/callbackUrlUtils.ts new file mode 100644 index 000000000..2018ead98 --- /dev/null +++ b/packages/lib/callbackUrlUtils.ts @@ -0,0 +1,37 @@ +const URL = require('url-parse'); + +export function isCallbackUrl(s: string) { + return s.startsWith('joplin://x-callback-url/'); +} + +export function getNoteCallbackUrl(noteId: string) { + return `joplin://x-callback-url/openNote?id=${encodeURIComponent(noteId)}`; +} + +export function getFolderCallbackUrl(folderId: string) { + return `joplin://x-callback-url/openFolder?id=${encodeURIComponent(folderId)}`; +} + +export function getTagCallbackUrl(tagId: string) { + return `joplin://x-callback-url/openTag?id=${encodeURIComponent(tagId)}`; +} + +export const enum CallbackUrlCommand { + OpenNote = 'openNote', + OpenFolder = 'openFolder', + OpenTag = 'openTag', +} + +export interface CallbackUrlInfo { + command: CallbackUrlCommand; + params: Record; +} + +export function parseCallbackUrl(s: string): CallbackUrlInfo { + if (!isCallbackUrl(s)) throw new Error(`Invalid callback url ${s}`); + const url = new URL(s, true); + return { + command: url.pathname.substring(url.pathname.lastIndexOf('/') + 1) as CallbackUrlCommand, + params: url.query, + }; +}