Expose subscribe on Accounts interface (#42)

* Expose subscribe on Accounts interface

* Basic subscription for accounts on background

* Subscriptions should be in-place...

* Add web3AccountsSubscribe
This commit is contained in:
Jaco Greeff 2019-05-31 16:36:07 +02:00 committed by GitHub
parent 43fc1314bf
commit d7b0b6fcf5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 176 additions and 58 deletions

View File

@ -47,7 +47,7 @@ window.injectedWeb3 = {
}
```
When there is more than one extension, each will populate an entry above, so from an extension implementation perspective, the structure should not be overridded. The injected interface, when returned via `enable`, contains the following -
When there is more than one extension, each will populate an entry above, so from an extension implementation perspective, the structure should not be overridded. The `Injected` interface, as returned via `enable`, contains the following -
```js
interface Injected {
@ -57,12 +57,17 @@ interface Injected {
// readonly provider: Provider
}
interface Account = {
readonly address: string; // ss-58 encoded address
readonly name?: string; // optional name for display
};
// exposes accounts
interface Accounts {
get (): Promise<Array<{
readonly address: string; // ss-58 encoded address
readonly name?: string; // optional name for display
}>>;
// retrieves the list of accounts for right now
get (): Promise<Array<Account>>;
// subscribe to all accounts, updating as they change
subscribe (cb: (accounts: Array<Account>) => any): () => void
}
// a signer that communicates with the extension via sendMessage

View File

@ -2,12 +2,13 @@
A basic extractor that manipulates the `window.injectedWeb3` to retrieve all the providers added to the page. It has a number of utilities -
- `web3Enable(dappName: string)` - to be called before anything else, retrieves the list of all injected extensions/providers
- `web3Accounts()` - returns a list of all the injected accounts, accross all extensions (source in meta)
- `web3FromAddress(address: string)` - Retrieves a provider for a specific address
- `web3FromSource(name: string)` - Retriebes a provider identified by the name
- `isWeb3Injected` - boolean to indicate if `injectedWeb3` was found on the page
- `web3EnablePromise` - `null` or the value of the last call to `web3Enable`
- `web3Enable(dappName: string): Promise<Array<InjectedExtension>>` - to be called before anything else, retrieves the list of all injected extensions/providers
- `web3Accounts(): Promise<Array<InjectedAccountWithMeta>>` - returns a list of all the injected accounts, accross all extensions (source in meta)
- `web3AccountsSubscribe(cb: (accounts: Array<InjectedAccountWithMeta>) => any): Promise<Unsubcall>` - subscribes to the accounts accross all extensions, returning a full list as changes are made
- `web3FromAddress(address: string): Promise<InjectedExtension>` - Retrieves a provider for a specific address
- `web3FromSource(name: string): Promise<InjectedExtension>` - Retrieves a provider identified by the name
- `isWeb3Injected: boolean` - boolean to indicate if `injectedWeb3` was found on the page
- `web3EnablePromise: Promise<Array<InjectedExtension>> | null` - `null` or the promise as a result of the last call to `web3Enable`
## Usage

View File

@ -4,6 +4,7 @@
import singleSource from './singleSource';
// initialize all the compatibility engines
export default function initCompat (): Promise<boolean> {
return Promise.all([
singleSource()

View File

@ -5,9 +5,11 @@
import { Signer } from '@polkadot/api/types';
import { InjectedAccount, InjectedWindow } from '../types';
// RxJs interface, only what we need here
// RxJs interface, only the bare-bones of what we need here
type Subscriber<T> = {
subscribe: (cb: (value: Array<T>) => void) => void
subscribe: (cb: (value: T) => void) => {
unsubscribe (): any
}
};
type SingleSourceAccount = {
@ -17,7 +19,7 @@ type SingleSourceAccount = {
};
type SingleSource = {
accounts$: Subscriber<SingleSourceAccount>,
accounts$: Subscriber<Array<SingleSourceAccount>>,
environment$: Subscriber<string>,
signer: Signer
};
@ -26,6 +28,14 @@ type SingleWindow = Window & InjectedWindow & {
SingleSource: SingleSource
};
// transfor the SingleSource accounts into a simple address/name array
function transformAccounts (accounts: Array<SingleSourceAccount>): Array<InjectedAccount> {
return accounts.map(({ address, name }) => ({
address,
name
}));
}
// add a compat interface of SingleSource to window.injectedWeb3
function injectSingleSource (win: SingleWindow): void {
let accounts: Array<InjectedAccount> = [];
@ -33,17 +43,24 @@ function injectSingleSource (win: SingleWindow): void {
// we don't yet have an accounts subscribe on the interface, simply get the
// accounts and store them, any get will resolve the last found values
win.SingleSource.accounts$.subscribe((_accounts) => {
accounts = _accounts.map(({ address, name }) => ({
address,
name
}));
accounts = transformAccounts(_accounts);
});
// decorate the compat interface
win.injectedWeb3['SingleSource'] = {
enable: async (origin: string) => ({
accounts: {
get: async () => accounts
get: async () =>
accounts,
subscribe: (cb: (accounts: Array<InjectedAccount>) => any) => {
const sub = win.SingleSource.accounts$.subscribe((accounts) =>
cb(transformAccounts(accounts))
);
return (): void => {
sub.unsubscribe();
};
}
},
signer: win.SingleSource.signer
}),

View File

@ -2,7 +2,7 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { InjectedAccountWithMeta, InjectedExtension, InjectedExtensionInfo, InjectedWindow } from './types';
import { InjectedAccount, InjectedAccountWithMeta, InjectedExtension, InjectedExtensionInfo, InjectedWindow, Unsubcall } from './types';
// our extension adaptor for other kinds of extensions
import compatInjector from './compat';
@ -18,6 +18,19 @@ function web3IsInjected (): boolean {
return Object.keys(win.injectedWeb3).length !== 0;
}
// helper to throw a consistent error when not enabled
function throwError (method: string): never {
throw new Error(`${method}: web3Enable(originName) needs to be called before ${method}`);
}
// internal helper to map from Array<InjectedAccount> -> Array<InjectedAccountWithMeta>
function mapAccounts (source: string, list: Array<InjectedAccount>): Array<InjectedAccountWithMeta> {
return list.map(({ address, name }) => ({
address,
meta: { name, source }
}));
}
// have we found a properly constructed window.injectedWeb3
let isWeb3Injected = web3IsInjected();
@ -56,7 +69,7 @@ export function web3Enable (originName: string): Promise<Array<InjectedExtension
// retrieve all the accounts accross all providers
export async function web3Accounts (): Promise<Array<InjectedAccountWithMeta>> {
if (!web3EnablePromise) {
throw new Error(`web3Accounts: web3Enable(originName) needs to be called before web3Accounts`);
return throwError('web3Accounts');
}
const accounts: Array<InjectedAccountWithMeta> = [];
@ -66,10 +79,7 @@ export async function web3Accounts (): Promise<Array<InjectedAccountWithMeta>> {
try {
const list = await accounts.get();
return list.map(({ address, name }) => ({
address,
meta: { name, source }
}));
return mapAccounts(source, list);
} catch (error) {
// cannot handle this one
return [];
@ -86,10 +96,36 @@ export async function web3Accounts (): Promise<Array<InjectedAccountWithMeta>> {
return accounts;
}
export async function web3AccountsSubscribe (cb: (accounts: Array<InjectedAccountWithMeta>) => any): Promise<Unsubcall> {
if (!web3EnablePromise) {
return throwError('web3AccountsSubscribe');
}
const accounts: { [source: string]: Array<InjectedAccount> } = {};
const triggerUpdate = (): void => {
cb(Object.entries(accounts).reduce((result, [source, list]) => {
result.push(...mapAccounts(source, list));
return result;
}, [] as Array<InjectedAccountWithMeta>));
};
const unsubs = (await web3EnablePromise).map(({ accounts: { subscribe }, name: source }) =>
subscribe((result) => {
accounts[source] = result;
triggerUpdate();
})
);
return (): void => {
unsubs.forEach((unsub) => unsub());
};
}
// find a specific provider based on an address
export async function web3FromAddress (address: string): Promise<InjectedExtension> {
if (!web3EnablePromise) {
throw new Error(`web3FromAddress: web3Enable(originName) needs to be called before web3FromAddress`);
return throwError('web3FromAddress');
}
const accounts = await web3Accounts();
@ -105,7 +141,7 @@ export async function web3FromAddress (address: string): Promise<InjectedExtensi
// find a specific provider based on the name
export async function web3FromSource (source: string): Promise<InjectedExtension> {
if (!web3EnablePromise) {
throw new Error(`web3FromSource: web3Enable(originName) needs to be called before web3FromSource`);
return throwError('web3FromSource');
}
const sources = await web3EnablePromise;

View File

@ -4,6 +4,8 @@
import { Signer } from '@polkadot/api/types';
export type Unsubcall = () => void;
export interface InjectedAccount {
address: string;
name: string;
@ -19,6 +21,7 @@ export interface InjectedAccountWithMeta {
export interface InjectedAccounts {
get: () => Promise<Array<InjectedAccount>>;
subscribe: (cb: (accounts: Array<InjectedAccount>) => any) => Unsubcall;
}
export interface InjectedSigner extends Signer {}

View File

@ -2,14 +2,24 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { Unsubcall } from '@polkadot/extension-dapp/types';
import { SubjectInfo } from '@polkadot/ui-keyring/observable/types';
import { MessageTypes, MessageAuthorize, MessageExtrinsicSign, MessageExtrinsicSign$Response } from '../types';
import keyring from '@polkadot/ui-keyring';
import accountsObservable from '@polkadot/ui-keyring/observable/accounts';
import { assert } from '@polkadot/util';
import { assert, isFunction } from '@polkadot/util';
import State from './State';
type Accounts = Array<{ address: string, name?: string }>;
function transformAccounts (accounts: SubjectInfo): Accounts {
return Object.values(accounts).map(({ json: { address, meta: { name } } }) => ({
address, name
}));
}
export default class Tabs {
private _state: State;
@ -21,12 +31,22 @@ export default class Tabs {
return this._state.authorizeUrl(url, request);
}
private accountsList (url: string): Array<{ address: string, name?: string }> {
return Object
.values(accountsObservable.subject.getValue())
.map(({ json: { address, meta: { name } } }) => ({
address, name
}));
private accountsList (url: string): Accounts {
return transformAccounts(accountsObservable.subject.getValue());
}
private accountsSubscribe (url: string, cb?: (accounts: Accounts) => void): Unsubcall {
if (!isFunction(cb)) {
throw new Error('Expected accountsSubscribe to be passed a subscriber');
}
const subscription = accountsObservable.subject.subscribe((accounts: SubjectInfo) =>
cb(transformAccounts(accounts))
);
return (): void => {
subscription.unsubscribe();
};
}
private extrinsicSign (url: string, request: MessageExtrinsicSign): Promise<MessageExtrinsicSign$Response> {
@ -38,7 +58,7 @@ export default class Tabs {
return this._state.signQueue(url, request);
}
async handle (type: MessageTypes, request: any, url: string = 'unknown'): Promise<any> {
async handle (type: MessageTypes, request: any, url: string, subscriber?: (data: any) => void): Promise<any> {
switch (type) {
case 'authorize.tab':
return this.authorize(url, request);
@ -46,6 +66,9 @@ export default class Tabs {
case 'accounts.list':
return this.accountsList(url);
case 'accounts.subscribe':
return this.accountsSubscribe(url, subscriber);
case 'extrinsic.sign':
return this.extrinsicSign(url, request);

View File

@ -9,38 +9,54 @@ import Extension from './Extension';
import State from './State';
import Tabs from './Tabs';
const FALLBACK_URL = '<unknown>';
const state = new State();
const extension = new Extension(state);
const tabs = new Tabs(state);
export default function handler ({ id, message, request }: MessageRequest, port: chrome.runtime.Port): boolean {
function createSubscription (id: number, port: chrome.runtime.Port): (data: any) => void {
// FIXME We are not handling actual unsubscribes yet, this is an issue
return (subscription: any) => {
port.postMessage({ id, subscription });
};
}
export default function handler ({ id, message, request }: MessageRequest, port: chrome.runtime.Port): void {
const isPopup = port.name === PORT_POPUP;
const sender = port.sender as chrome.runtime.MessageSender;
const from = isPopup
? 'popup'
: sender.tab
? sender.tab.url
: '<unknown>';
const source = `${from}: ${id}: ${message}`;
: sender.tab && sender.tab.url;
const source = `${from || FALLBACK_URL}: ${id}: ${message}`;
console.log(` [in] ${source}`); // :: ${JSON.stringify(request)}`);
// This is not great - basically, based on the name (since there is only 1 atm),
// we create a subscription handler. Basically these handlers will continute stream
// results as they become available
const subscription = message.indexOf('.subscribe') !== -1
? createSubscription(id, port)
: undefined;
const promise = isPopup
? extension.handle(message, request)
: tabs.handle(message, request, from);
: tabs.handle(message, request, from || FALLBACK_URL, subscription);
promise
.then((response) => {
console.log(`[out] ${source}`); // :: ${JSON.stringify(response)}`);
port.postMessage({ id, response });
if (subscription) {
// TODO See unsub handling above, here we need to store the
// actual unsubscribe and handle appropriately
port.postMessage({ id, response: true });
} else {
port.postMessage({ id, response });
}
})
.catch((error) => {
console.log(`[err] ${source}:: ${error.message}`);
port.postMessage({ id, error: error.message });
});
// return true to indicate we are sending the response async
return true;
}

View File

@ -4,7 +4,7 @@
import { KeypairType } from '@polkadot/util-crypto/types';
export type MessageTypes = 'authorize.approve' | 'authorize.reject' | 'authorize.requests' | 'authorize.tab' | 'accounts.create' | 'accounts.edit' | 'accounts.forget' | 'accounts.list' | 'extrinsic.sign' | 'seed.create' | 'seed.validate' | 'signing.approve' | 'signing.cancel' | 'signing.requests';
export type MessageTypes = 'authorize.approve' | 'authorize.reject' | 'authorize.requests' | 'authorize.tab' | 'accounts.create' | 'accounts.edit' | 'accounts.forget' | 'accounts.list' | 'accounts.subscribe' | 'extrinsic.sign' | 'seed.create' | 'seed.validate' | 'signing.approve' | 'signing.cancel' | 'signing.requests';
export type AuthorizeRequest = [number, MessageAuthorize, string];
@ -31,7 +31,8 @@ export type MessageRequest = {
export type MessageResponse = {
error?: string,
id: number,
response?: any
response?: any,
subscription?: any
};
export type MessageAccountCreate = {

View File

@ -2,7 +2,7 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { InjectedAccounts, InjectedAccount } from '@polkadot/extension-dapp/types';
import { InjectedAccounts, InjectedAccount, Unsubcall } from '@polkadot/extension-dapp/types';
import { SendRequest } from './types';
let sendRequest: SendRequest;
@ -15,4 +15,13 @@ export default class Accounts implements InjectedAccounts {
get (): Promise<Array<InjectedAccount>> {
return sendRequest('accounts.list');
}
subscribe (cb: (accounts: Array<InjectedAccount>) => any): Unsubcall {
sendRequest('accounts.subscribe', null, cb)
.catch(console.error);
return () => {
// FIXME we need the ability to unsubscribe
};
}
}

View File

@ -7,17 +7,18 @@ import { MessageTypes } from '../background/types';
import Injected from './Injected';
// when sending a message from the injector to the expension, we
// when sending a message from the injector to the extension, we
// - create an event - this we send to the loader
// - the loader takes this evend and uses sendMessage to the extension background
// - on respoinse, the loader creates a reponse event
// - this injector, reads listends on the events, maps it to the original
// - resolves/rejects the promise with the result
// - the loader takes this event and uses port.postMessage to background
// - on resposnse, the loader creates a reponse event
// - this injector, listens on the events, maps it to the original
// - resolves/rejects the promise with the result (or sub data)
type Handlers = {
[index: number]: {
resolve: (data: any) => void,
reject: (error: Error) => void
reject: (error: Error) => void,
subscriber?: (data: any) => void
}
};
@ -28,11 +29,11 @@ let idCounter = 0;
// a generic message sender that creates an event, returning a promise that will
// resolve once the event is resolved (by the response listener just below this)
function sendMessage (message: MessageTypes, request: any = null): Promise<any> {
function sendMessage (message: MessageTypes, request: any = null, subscriber?: (data: any) => void): Promise<any> {
return new Promise((resolve, reject) => {
const id = ++idCounter;
handlers[id] = { resolve, reject };
handlers[id] = { resolve, reject, subscriber };
window.postMessage({ id, message, origin: 'page', request }, '*');
});
@ -59,7 +60,12 @@ window.addEventListener('message', ({ data, source }) => {
return;
}
delete handlers[data.id];
if (!handler.subscriber) {
delete handlers[data.id];
} else if (data.subscription) {
handler.subscriber(data.subscription);
return;
}
if (data.error) {
handler.reject(new Error(data.error));

View File

@ -5,5 +5,5 @@
import { MessageTypes } from '../background/types';
export interface SendRequest {
(message: MessageTypes, request?: any): Promise<any>;
(message: MessageTypes, request?: any, subscriber?: (data: any) => any): Promise<any>;
}