Skip to content

Commit

Permalink
wallet-ext: content script, ui connections to background service
Browse files Browse the repository at this point in the history
* adds a demo method to the dapp to get the user's account
* requests user's permission if not already given
  • Loading branch information
pchrysochoidis committed Jun 30, 2022
1 parent 3fe4cee commit 1ffa541
Show file tree
Hide file tree
Showing 20 changed files with 670 additions and 25 deletions.
184 changes: 184 additions & 0 deletions wallet/src/background/Permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Copyright (c) 2022, Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { filter, lastValueFrom, map, race, Subject, take, tap } from 'rxjs';
import { v4 as uuidV4 } from 'uuid';
import Browser from 'webextension-polyfill';

import { Window } from './Window';

import type { ContentScriptConnection } from './connections/ContentScriptConnection';
import type {
Permission,
PermissionResponse,
PermissionType,
} from '_messages/payloads/permissions';

function openPermissionWindow(permissionID: string) {
return new Window(
Browser.runtime.getURL('ui.html') +
`#/connect/${encodeURIComponent(permissionID)}`
);
}

const PERMISSIONS_STORAGE_KEY = 'permissions';

class Permissions {
private _permissionResponses: Subject<PermissionResponse> = new Subject();

public async acquirePermissions(
permissionTypes: PermissionType[],
connection: ContentScriptConnection
): Promise<Permission> {
const { origin } = connection;
const existingPermission = await this.getPermission(origin);
const hasPendingRequest = await this.hasPendingPermissionRequest(
origin,
existingPermission
);
if (hasPendingRequest) {
throw new Error('Another permission request is pending.');
}
const alreadyAllowed = await this.hasPermissions(
origin,
permissionTypes,
existingPermission
);
if (alreadyAllowed && existingPermission) {
return existingPermission;
}
const pRequest = await this.createPermissionRequest(
connection.origin,
permissionTypes,
connection.originFavIcon,
existingPermission
);
const permissionWindow = openPermissionWindow(pRequest.id);
const onWindowCloseStream = await permissionWindow.show();
const responseStream = this._permissionResponses.pipe(
filter((resp) => resp.id === pRequest.id),
map((resp) => {
pRequest.allowed = resp.allowed;
pRequest.accounts = resp.accounts;
pRequest.responseDate = resp.responseDate;
return pRequest;
}),
tap(() => permissionWindow.close())
);
return lastValueFrom(
race(
onWindowCloseStream.pipe(
map(() => {
pRequest.allowed = false;
pRequest.accounts = [];
pRequest.responseDate = new Date().toISOString();
return pRequest;
})
),
responseStream
).pipe(
take(1),
tap(async (permission) => {
await this.storePermission(permission);
}),
map((permission) => {
if (!permission.allowed) {
throw new Error('Permission rejected');
}
return permission;
})
)
);
}

public handlePermissionResponse(response: PermissionResponse) {
this._permissionResponses.next(response);
}

public async getPermissions(): Promise<Record<string, Permission>> {
return (
await Browser.storage.local.get({ [PERMISSIONS_STORAGE_KEY]: {} })
)[PERMISSIONS_STORAGE_KEY];
}

public async getPermission(
origin: string,
permission?: Permission | null
): Promise<Permission | null> {
if (permission && permission.origin !== origin) {
throw new Error(
`Provided permission has different origin from the one provided. "${permission.origin} !== ${origin}"`
);
}
if (permission) {
return permission;
}
const permissions = await this.getPermissions();
return permissions[origin] || null;
}

public async hasPendingPermissionRequest(
origin: string,
permission?: Permission | null
): Promise<boolean> {
const existingPermission = await this.getPermission(origin, permission);
return !!existingPermission && existingPermission.responseDate === null;
}

public async hasPermissions(
origin: string,
permissionTypes: PermissionType[],
permission?: Permission | null
): Promise<boolean> {
const existingPermission = await this.getPermission(origin, permission);
return Boolean(
existingPermission &&
existingPermission.allowed &&
permissionTypes.every((permissionType) =>
existingPermission.permissions.includes(permissionType)
)
);
}

private async createPermissionRequest(
origin: string,
permissionTypes: PermissionType[],
favIcon: string | undefined,
existingPermission?: Permission | null
): Promise<Permission> {
let permissionToStore: Permission;
if (existingPermission) {
existingPermission.allowed = null;
existingPermission.responseDate = null;
permissionTypes.forEach((aPermission) => {
if (!existingPermission.permissions.includes(aPermission)) {
existingPermission.permissions.push(aPermission);
}
});
permissionToStore = existingPermission;
} else {
permissionToStore = {
id: uuidV4(),
accounts: [],
allowed: null,
createdDate: new Date().toISOString(),
origin,
favIcon,
permissions: permissionTypes,
responseDate: null,
};
}
await this.storePermission(permissionToStore);
return permissionToStore;
}

private async storePermission(permission: Permission) {
const permissions = await this.getPermissions();
permissions[permission.origin] = permission;
await Browser.storage.local.set({
[PERMISSIONS_STORAGE_KEY]: permissions,
});
}
}

export default new Permissions();
48 changes: 48 additions & 0 deletions wallet/src/background/Window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright (c) 2022, Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { filter, fromEventPattern, share, take, takeWhile } from 'rxjs';
import Browser from 'webextension-polyfill';

const windowRemovedStream = fromEventPattern<number>(
(handler) => Browser.windows.onRemoved.addListener(handler),
(handler) => Browser.windows.onRemoved.removeListener(handler)
).pipe(share());

export class Window {
private _id: number | null = null;
private _url: string;

constructor(url: string) {
this._url = url;
}

public async show() {
const {
width = 0,
left = 0,
top = 0,
} = await Browser.windows.getLastFocused();
const w = await Browser.windows.create({
url: this._url,
focused: true,
width: 370,
height: 460,
type: 'popup',
top: top,
left: Math.floor(left + width - 450),
});
this._id = typeof w.id === 'undefined' ? null : w.id;
return windowRemovedStream.pipe(
takeWhile(() => this._id !== null),
filter((aWindowID) => aWindowID === this._id),
take(1)
);
}

public async close() {
if (this._id !== null) {
await Browser.windows.remove(this._id);
}
}
}
33 changes: 33 additions & 0 deletions wallet/src/background/connections/Connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) 2022, Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { map, take } from 'rxjs';

import { PortStream } from '_messaging/PortStream';

import type { Message } from '_messages';
import type { Runtime } from 'webextension-polyfill';

export abstract class Connection {
protected _portStream: PortStream;

constructor(port: Runtime.Port) {
this._portStream = new PortStream(port);
this._portStream.onMessage.subscribe((msg) => this.handleMessage(msg));
}

public get onDisconnect() {
return this._portStream.onDisconnect.pipe(
map((port) => ({ port, connection: this })),
take(1)
);
}

protected abstract handleMessage(msg: Message): void;

protected send(msg: Message) {
if (this._portStream.connected) {
return this._portStream.sendMessage(msg);
}
}
}
80 changes: 80 additions & 0 deletions wallet/src/background/connections/ContentScriptConnection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2022, Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { Connection } from './Connection';
import { createMessage } from '_messages';
import { isGetAccount } from '_payloads/account/GetAccount';
import Permissions from '_src/background/Permissions';

import type { SuiAddress } from '@mysten/sui.js';
import type { Message } from '_messages';
import type { PortChannelName } from '_messaging/PortChannelName';
import type { ErrorPayload } from '_payloads';
import type { GetAccountResponse } from '_payloads/account/GetAccountResponse';
import type { Runtime } from 'webextension-polyfill';

export class ContentScriptConnection extends Connection {
public static readonly CHANNEL: PortChannelName =
'sui_content<->background';
public readonly origin: string;
public readonly originFavIcon: string | undefined;

constructor(port: Runtime.Port) {
super(port);
this.origin = this.getOrigin(port);
this.originFavIcon = port.sender?.tab?.favIconUrl;
}

protected async handleMessage(msg: Message) {
const { payload } = msg;
if (isGetAccount(payload)) {
try {
const permission = await Permissions.acquirePermissions(
['viewAccount'],
this
);
this.sendAccounts(permission.accounts, msg.id);
} catch (e) {
this.sendError(
{
error: true,
message: (e as Error).toString(),
code: -1,
},
msg.id
);
}
}
}

private getOrigin(port: Runtime.Port) {
if (port.sender?.origin) {
return port.sender.origin;
}
if (port.sender?.url) {
return new URL(port.sender.url).origin;
}
throw new Error(
"[ContentScriptConnection] port doesn't include an origin"
);
}

private sendError<Error extends ErrorPayload>(
error: Error,
responseForID?: string
) {
this.send(createMessage(error, responseForID));
}

private sendAccounts(accounts: SuiAddress[], responseForID?: string) {
this.send(
createMessage<GetAccountResponse>(
{
type: 'get-account-response',
accounts,
},
responseForID
)
);
}
}
42 changes: 42 additions & 0 deletions wallet/src/background/connections/UiConnection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) 2022, Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { Connection } from './Connection';
import { createMessage } from '_messages';
import {
isGetPermissionRequests,
isPermissionResponse,
} from '_payloads/permissions';
import Permissions from '_src/background/Permissions';

import type { Message } from '_messages';
import type { PortChannelName } from '_messaging/PortChannelName';
import type { Permission, PermissionRequests } from '_payloads/permissions';

export class UiConnection extends Connection {
public static readonly CHANNEL: PortChannelName = 'sui_ui<->background';

protected async handleMessage(msg: Message) {
const { payload, id } = msg;
if (isGetPermissionRequests(payload)) {
this.sendPermissions(
Object.values(await Permissions.getPermissions()),
id
);
} else if (isPermissionResponse(payload)) {
Permissions.handlePermissionResponse(payload);
}
}

private sendPermissions(permissions: Permission[], requestID: string) {
this.send(
createMessage<PermissionRequests>(
{
type: 'permission-request',
permissions,
},
requestID
)
);
}
}
Loading

0 comments on commit 1ffa541

Please sign in to comment.