Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Desktop: Handle URL links #5302

Merged
merged 25 commits into from
Sep 3, 2021
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b17d8bc
Register to handle joplin:// links
roman-r-m Aug 12, 2021
f909fe6
Trying to get it to work on Linux
roman-r-m Aug 13, 2021
0a6390e
Actually open the note
roman-r-m Aug 13, 2021
0050489
Cleanup
roman-r-m Aug 13, 2021
6116103
Open the note from URL even if Joplin isn't running
roman-r-m Aug 13, 2021
f118f52
Handle openFolder and openTag too; change the URL format; extract ULR…
roman-r-m Aug 14, 2021
047883b
Read initial url from a field
roman-r-m Aug 14, 2021
f454c4e
Add a function to check for valid callback url
roman-r-m Aug 14, 2021
305d0ff
Rename copy folder URL -> copy notebook URL
roman-r-m Aug 14, 2021
ecf7180
Code review comments
roman-r-m Aug 14, 2021
f0361bf
Review comments - escape vars in url
roman-r-m Aug 14, 2021
2b6b4dd
Rename initialUrl -> initialCallbackUrl
roman-r-m Aug 14, 2021
ee46978
Review comments - throw an error if callback url is not valid
roman-r-m Aug 14, 2021
b02baa6
Update ignore files
roman-r-m Aug 14, 2021
90621a8
Fix linter errors
roman-r-m Aug 14, 2021
2386abe
Rename parseUrl -> parseCallbackUrl
roman-r-m Aug 14, 2021
20d1f74
Use enum
roman-r-m Aug 14, 2021
6c18c6d
Use url-parse
roman-r-m Aug 15, 2021
e73a4b7
Merge branch 'dev' into desktop-protocol
roman-r-m Aug 20, 2021
62c5f43
Rename enum values
roman-r-m Aug 20, 2021
f42fd0e
Fix enum usage
roman-r-m Aug 20, 2021
b269c2f
Simplify protocol setup
roman-r-m Aug 28, 2021
1126899
Code review changes
roman-r-m Sep 1, 2021
1a703c4
Rename ProtocolUtils -> callbackUrlUtils
roman-r-m Sep 1, 2021
886b6d1
Add stuff to maybe get it to work on MacOS
roman-r-m Sep 1, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,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
Expand Down
27 changes: 25 additions & 2 deletions packages/app-desktop/ElectronAppWrapper.ts
Original file line number Diff line number Diff line change
@@ -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/ProtocolUtils';

const { BrowserWindow, Tray, screen } = require('electron');
const url = require('url');
Expand Down Expand Up @@ -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() {
Expand All @@ -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_;
Expand Down Expand Up @@ -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.openUrl(url);
}
}
});

return false;
Expand All @@ -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.openUrl(url);
});
}

async openUrl(url: string) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be "openCallbackUrl" to keep the same naming convention.

this.win_.webContents.send('asynchronous-message', 'openUrl', {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"openCallbackUrl"

url: url,
});
}

}
19 changes: 19 additions & 0 deletions packages/app-desktop/gui/MainScreen/MainScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/ProtocolUtils';
import ElectronAppWrapper from '../../ElectronAppWrapper';
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';

const { connect } = require('react-redux');
Expand Down Expand Up @@ -187,6 +189,23 @@ class MainScreenComponent extends React.Component<Props, State> {
this.layoutModeListenerKeyDown = this.layoutModeListenerKeyDown.bind(this);

window.addEventListener('resize', this.window_resize);

ipcRenderer.on('asynchronous-message', (_event: any, message: string, args: any) => {
if (message === 'openUrl') {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"openCallbackUrl"

this.openUrl(args.url);
}
});

const initialCallbackUrl = (bridge().electronApp() as ElectronAppWrapper).initialCallbackUrl();
if (initialCallbackUrl) {
this.openUrl(initialCallbackUrl);
}
}

private openUrl(url: string) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openCallbackUrl

console.log(`openUrl ${url}`);
const { command, params } = parseCallbackUrl(url);
void CommandService.instance().execute(command.toString(), params.id);
}

private updateLayoutPluginViews(layout: LayoutItem, plugins: PluginStates) {
Expand Down
21 changes: 21 additions & 0 deletions packages/app-desktop/gui/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { getFolderUrl, getTagUrl } from '@joplin/lib/ProtocolUtils';
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think all these callback URL functions should either be in their own namespace so that you can have for example callbackUrls.getFolderUrl or else all functions should be consistently using "callbackUrl" and not just "url". So in that case it would be getFolderCallbackUrl and getTagCallbackUrl.

It's certainly a bit verbose but the nature of this feature means the code is a bit everywhere so it's good to be able to quickly find it and identify it if needed.

const { connect } = require('react-redux');
const shared = require('@joplin/lib/components/shared/side-menu-shared.js');
const { themeStyle } = require('@joplin/lib/theme');
Expand All @@ -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');

Expand Down Expand Up @@ -326,10 +328,29 @@ class SidebarComponent extends React.Component<Props, State> {
);
}

if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem({
label: _('Copy notebook URL'),
click: () => {
clipboard.writeText(getFolderUrl(itemId));
},
})
);
}

if (itemType === BaseModel.TYPE_TAG) {
menu.append(new MenuItem(
menuUtils.commandToStatefulMenuItem('renameTag', itemId)
));
menu.append(
new MenuItem({
label: _('Copy tag URL'),
click: () => {
clipboard.writeText(getTagUrl(itemId));
},
})
);
}

const pluginViews = pluginUtils.viewsByType(this.pluginsRef.current, 'menuItem');
Expand Down
14 changes: 13 additions & 1 deletion packages/app-desktop/gui/utils/NoteListUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { getNoteUrl } from '@joplin/lib/ProtocolUtils';

import BaseModel from '@joplin/lib/BaseModel';
const bridge = require('electron').remote.require('./bridge').default;
Expand All @@ -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[];
Expand Down Expand Up @@ -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]);
Expand All @@ -133,6 +134,17 @@ export default class NoteListUtils {
})
);

if (noteIds.length == 1) {
menu.append(
new MenuItem({
label: _('Copy note URL'),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit confusing because we already have "Copy markdown link". I think we need to discuss all this in the spec and perhaps also see how it's done in other applications?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Markdown link is a different format though.

I'm not sure how people would use this function but it seems logical to allow for easy creation of links to items that can be linked.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about calling this "Copy external link"? (and then same for tags and folders)

click: () => {
clipboard.writeText(getNoteUrl(noteIds[0]));
},
})
);
}

if ([9, 10].includes(Setting.value('sync.target'))) {
menu.append(
new MenuItem(
Expand Down
7 changes: 6 additions & 1 deletion packages/app-desktop/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion packages/app-desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@
"icon": "../../Assets/LinuxIcons",
"category": "Office",
"desktop": {
"Icon": "joplin"
"Icon": "joplin",
"MimeType": "x-scheme-handler/joplin;"
},
"target": "AppImage"
},
Expand Down
37 changes: 37 additions & 0 deletions packages/lib/ProtocolUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const URL = require('url-parse');
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProtocolUtils should be lowercase "protocolUtils". In general, util files should be lowercase and classes uppercase.

But in fact could you call it "callbackUrlUtils"?


export function isCallbackUrl(s: string) {
return s.startsWith('joplin://x-callback-url/');
}

export function getNoteUrl(noteId: string) {
return `joplin://x-callback-url/openNote?id=${encodeURIComponent(noteId)}`;
}

export function getFolderUrl(folderId: string) {
return `joplin://x-callback-url/openFolder?id=${encodeURIComponent(folderId)}`;
}

export function getTagUrl(tagId: string) {
return `joplin://x-callback-url/openTag?id=${encodeURIComponent(tagId)}`;
}

export const enum Command {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CallbackUrlCommand

OpenNote = 'openNote',
OpenFolder = 'openFolder',
OpenTag = 'openTag',
}

export interface CallbackUrlInfo {
command: Command;
params: Record<string, string>;
}

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 Command,
params: url.query,
};
}