Skip to content
This repository has been archived by the owner on Aug 1, 2022. It is now read-only.

Commit

Permalink
feat(ui): open Upstream via radicle:// (#1652)
Browse files Browse the repository at this point in the history
* Handle custom protocol on macOS
* Handle custom protocol on Linux
* Implement custom protocol safeguards according to spec

Signed-off-by: Rūdolfs Ošiņš <rudolfs@osins.org>
Co-authored-by: Thomas Scholtes <thomas@monadic.xyz>
  • Loading branch information
rudolfs and Thomas Scholtes committed Apr 12, 2021
1 parent c3a9de1 commit 1bcd941
Show file tree
Hide file tree
Showing 13 changed files with 361 additions and 28 deletions.
91 changes: 91 additions & 0 deletions cypress/integration/deep_linking.spec.ts
@@ -0,0 +1,91 @@
import * as commands from "../support/commands";
import * as ipcStub from "../support/ipc-stub";
import * as ipcTypes from "../../native/ipc-types";

context("deep linking", () => {
beforeEach(() => {
commands.resetProxyState();
commands.onboardUser();
cy.visit("./public/index.html");
});

context("when passing in a valid URL", () => {
it("opens the search modal and pre-fills the input field with the Radicle ID", () => {
ipcStub.getStubs().then(stubs => {
stubs.sendMessage({
kind: ipcTypes.MainMessageKind.CUSTOM_PROTOCOL_INVOCATION,
data: {
url:
"radicle://link/v0/rad:git:hnrkjm5z3rwae9g3n6jhyo6kzh9eup5ku5odo",
},
});
});

commands
.pick("search-modal", "search-input")
.should("have.value", "rad:git:hnrkjm5z3rwae9g3n6jhyo6kzh9eup5ku5odo");
commands
.pick("search-modal", "follow-toggle")
.should("contain", "Follow");
});
});

context("when passing in an invalid URL", () => {
it("shows an error notification", () => {
ipcStub.getStubs().then(stubs => {
stubs.sendMessage({
kind: ipcTypes.MainMessageKind.CUSTOM_PROTOCOL_INVOCATION,
data: {
url: "radicle://THIS_IS_NOT_A_VALID_URN",
},
});
});

commands
.pick("notification")
.should("contain", "Could not parse the provided URL");

ipcStub.getStubs().then(stubs => {
stubs.sendMessage({
kind: ipcTypes.MainMessageKind.CUSTOM_PROTOCOL_INVOCATION,
data: {
url: "radicle://ethereum/v0/",
},
});
});

commands
.pick("notification")
.should(
"contain",
`The custom protocol namespace "ethereum" is not supported`
);

ipcStub.getStubs().then(stubs => {
stubs.sendMessage({
kind: ipcTypes.MainMessageKind.CUSTOM_PROTOCOL_INVOCATION,
data: {
url: "radicle://link/v1/",
},
});
});

commands
.pick("notification")
.should("contain", "The custom protocol version v1 is not supported");

ipcStub.getStubs().then(stubs => {
stubs.sendMessage({
kind: ipcTypes.MainMessageKind.CUSTOM_PROTOCOL_INVOCATION,
data: {
url: "radicle://link/v0/",
},
});
});

commands
.pick("notification")
.should("contain", "The provided URL does not contain a Radicle ID");
});
});
});
79 changes: 63 additions & 16 deletions native/index.ts
Expand Up @@ -16,6 +16,7 @@ import {
MainProcess,
mainProcessMethods,
} from "./ipc-types";
import { handleCustomProtocolInvocation } from "./nativeCustomProtocolHandler";

const isDev = process.env.NODE_ENV === "development";

Expand Down Expand Up @@ -140,6 +141,18 @@ class WindowManager {

this.window = window;
}

focus() {
if (!this.window) {
return;
}

if (this.window.isMinimized()) {
this.window.restore();
}

this.window.focus();
}
}

const windowManager = new WindowManager();
Expand Down Expand Up @@ -230,27 +243,61 @@ app.on("will-quit", () => {
proxyProcessManager.kill();
});

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on("ready", () => {
proxyProcessManager.run().then(({ status, signal, output }) => {
// Handle custom protocol on macOS
app.on("open-url", (event, url) => {
event.preventDefault();
handleCustomProtocolInvocation(url, url => {
windowManager.sendMessage({
kind: MainMessageKind.PROXY_ERROR,
data: {
status,
signal,
output,
},
kind: MainMessageKind.CUSTOM_PROTOCOL_INVOCATION,
data: { url },
});
});
});

if (isDev) {
setupWatcher();
}
if (app.requestSingleInstanceLock()) {
// Handle custom protocol on Linux when Upstream is already running
app.on("second-instance", (_event, argv, _workingDirectory) => {
handleCustomProtocolInvocation(argv[1], url => {
windowManager.focus();
windowManager.sendMessage({
kind: MainMessageKind.CUSTOM_PROTOCOL_INVOCATION,
data: { url },
});
});
});

windowManager.open();
});
// Handle custom protocol on Linux when Upstream is not running
handleCustomProtocolInvocation(process.argv[1], url => {
windowManager.sendMessage({
kind: MainMessageKind.CUSTOM_PROTOCOL_INVOCATION,
data: { url },
});
});

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on("ready", () => {
proxyProcessManager.run().then(({ status, signal, output }) => {
windowManager.sendMessage({
kind: MainMessageKind.PROXY_ERROR,
data: {
status,
signal,
output,
},
});
});

if (isDev) {
setupWatcher();
}

windowManager.open();
});
} else {
app.quit();
}

// Quit when all windows are closed.
app.on("window-all-closed", () => {
Expand Down
19 changes: 15 additions & 4 deletions native/ipc-types.ts
@@ -1,11 +1,17 @@
// Messages sent from the main process to the renderer
export type MainMessage = {
kind: MainMessageKind.PROXY_ERROR;
data: ProxyError;
};
export type MainMessage =
| {
kind: MainMessageKind.PROXY_ERROR;
data: ProxyError;
}
| {
kind: MainMessageKind.CUSTOM_PROTOCOL_INVOCATION;
data: CustomProtocolInvocation;
};

export enum MainMessageKind {
PROXY_ERROR = "PROXY_ERROR",
CUSTOM_PROTOCOL_INVOCATION = "CUSTOM_PROTOCOL_INVOCATION",
}

// Payload for the ProxyError `MainMessage`.
Expand All @@ -15,6 +21,11 @@ export interface ProxyError {
output: string;
}

// Payload for the CustomProtocolInvocation `MainMessage`
export interface CustomProtocolInvocation {
url: string;
}

// RPC interface exposed by the main process to the renderer.
export interface MainProcess {
clipboardWriteText(text: string): Promise<void>;
Expand Down
51 changes: 51 additions & 0 deletions native/nativeCustomProtocolHandler.test.ts
@@ -0,0 +1,51 @@
import * as sinon from "sinon";
import { handleCustomProtocolInvocation } from "./nativeCustomProtocolHandler";

jest.useFakeTimers("modern");

beforeEach(() => {
jest.runAllTimers();
});

describe("handleCustomProtocolInvocation", () => {
it("passes valid URLs", () => {
const callback = sinon.stub();
handleCustomProtocolInvocation(
`radicle://link/v1/rad:git:hnrkj7qjesxx4omprbj5c6apd97ebc9e5izoo`,
callback
);
expect(callback.callCount).toEqual(1);
});

it("rejects empty strings", () => {
const callback = sinon.stub();
handleCustomProtocolInvocation("", callback);
expect(callback.callCount).toEqual(0);
});

it("passes URLs that are exactly 1024 bytes long", () => {
const callback = sinon.stub();
handleCustomProtocolInvocation(`radicle://${"x".repeat(1014)}`, callback);
expect(callback.callCount).toEqual(1);
});

it("rejects handling URLs longer than 1024 bytes", () => {
const callback = sinon.stub();
handleCustomProtocolInvocation(`radicle://${"x".repeat(1015)}`, callback);
expect(callback.callCount).toEqual(0);
});

it("rejects URLs that are not prefixed with radicle://", () => {
const callback = sinon.stub();
handleCustomProtocolInvocation("upstream://", callback);
expect(callback.callCount).toEqual(0);
});

it("throttles incomming requests", () => {
const callback = sinon.stub();
handleCustomProtocolInvocation("radicle://", callback);
handleCustomProtocolInvocation("radicle://", callback);
handleCustomProtocolInvocation("radicle://", callback);
expect(callback.callCount).toEqual(1);
});
});
23 changes: 23 additions & 0 deletions native/nativeCustomProtocolHandler.ts
@@ -0,0 +1,23 @@
import lodash from "lodash";

const THROTTLE_TIMEOUT = 1000; // 1 second

export const handleCustomProtocolInvocation: (
url: string,
callback: (url: string) => void
) => void = lodash.throttle(
(url, callback) => {
if (
typeof url !== "string" ||
url.length === 0 ||
Buffer.byteLength(url, "utf8") > 1024 ||
!url.toLowerCase().match(/^radicle:\/\//)
) {
return;
}

callback(url);
},
THROTTLE_TIMEOUT,
{ trailing: false }
);
8 changes: 8 additions & 0 deletions package.json
Expand Up @@ -40,6 +40,14 @@
"to": "assets"
}
],
"protocols": [
{
"name": "radicle",
"schemes": [
"radicle"
]
}
],
"linux": {
"target": [
"Appimage"
Expand Down
3 changes: 3 additions & 0 deletions ui/App.svelte
Expand Up @@ -7,6 +7,7 @@
import * as path from "./src/path.ts";
import * as remote from "./src/remote.ts";
import * as error from "./src/error.ts";
import * as customProtocolHandler from "./src/customProtocolHandler.ts";
import { fetch, session as store, Status } from "./src/session.ts";
import {
Expand Down Expand Up @@ -103,6 +104,8 @@
$: sessionIsUnsealed =
$store.status === remote.Status.Success &&
$store.data.status === Status.UnsealedSession;
customProtocolHandler.register();
</script>

<style>
Expand Down
6 changes: 3 additions & 3 deletions ui/Modal/Search.svelte
Expand Up @@ -7,6 +7,7 @@
import type { Project } from "../src/project";
import * as remote from "../src/remote";
import {
inputStore,
projectRequest as request,
projectSearch as store,
reset,
Expand All @@ -20,10 +21,9 @@
import { FollowToggle, Remote } from "../DesignSystem/Component";
let id: string;
let input: string = "";
let value: string;
$: value = input.trim();
$: value = $inputStore.trim();
const dispatch = createEventDispatcher();
const urnValidation = urnValidationStore();
Expand Down Expand Up @@ -133,7 +133,7 @@
<div class="search-bar">
<Input.Text
autofocus
bind:value={input}
bind:value={$inputStore}
dataCy="search-input"
inputStyle="height: 3rem; color: var(--color-foreground-level-6); border-radius: 0.5rem; border: 0; box-shadow: var(--color-shadows);"
on:keydown={onKeydown}
Expand Down

0 comments on commit 1bcd941

Please sign in to comment.