forked from MystenLabs/sui
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
wallet-ext: content script, ui connections to background service
* adds a demo method to the dapp to get the user's account * requests user's permission if not already given
- Loading branch information
1 parent
3fe4cee
commit 1ffa541
Showing
20 changed files
with
670 additions
and
25 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
80
wallet/src/background/connections/ContentScriptConnection.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) | ||
); | ||
} | ||
} |
Oops, something went wrong.