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 ```js
interface Injected { interface Injected {
@ -57,12 +57,17 @@ interface Injected {
// readonly provider: Provider // readonly provider: Provider
} }
interface Account = {
readonly address: string; // ss-58 encoded address
readonly name?: string; // optional name for display
};
// exposes accounts // exposes accounts
interface Accounts { interface Accounts {
get (): Promise<Array<{ // retrieves the list of accounts for right now
readonly address: string; // ss-58 encoded address get (): Promise<Array<Account>>;
readonly name?: string; // optional name for display // subscribe to all accounts, updating as they change
}>>; subscribe (cb: (accounts: Array<Account>) => any): () => void
} }
// a signer that communicates with the extension via sendMessage // 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 - 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 - `web3Enable(dappName: string): Promise<Array<InjectedExtension>>` - 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) - `web3Accounts(): Promise<Array<InjectedAccountWithMeta>>` - returns a list of all the injected accounts, accross all extensions (source in meta)
- `web3FromAddress(address: string)` - Retrieves a provider for a specific address - `web3AccountsSubscribe(cb: (accounts: Array<InjectedAccountWithMeta>) => any): Promise<Unsubcall>` - subscribes to the accounts accross all extensions, returning a full list as changes are made
- `web3FromSource(name: string)` - Retriebes a provider identified by the name - `web3FromAddress(address: string): Promise<InjectedExtension>` - Retrieves a provider for a specific address
- `isWeb3Injected` - boolean to indicate if `injectedWeb3` was found on the page - `web3FromSource(name: string): Promise<InjectedExtension>` - Retrieves a provider identified by the name
- `web3EnablePromise` - `null` or the value of the last call to `web3Enable` - `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 ## Usage

View File

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

View File

@ -5,9 +5,11 @@
import { Signer } from '@polkadot/api/types'; import { Signer } from '@polkadot/api/types';
import { InjectedAccount, InjectedWindow } from '../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> = { type Subscriber<T> = {
subscribe: (cb: (value: Array<T>) => void) => void subscribe: (cb: (value: T) => void) => {
unsubscribe (): any
}
}; };
type SingleSourceAccount = { type SingleSourceAccount = {
@ -17,7 +19,7 @@ type SingleSourceAccount = {
}; };
type SingleSource = { type SingleSource = {
accounts$: Subscriber<SingleSourceAccount>, accounts$: Subscriber<Array<SingleSourceAccount>>,
environment$: Subscriber<string>, environment$: Subscriber<string>,
signer: Signer signer: Signer
}; };
@ -26,6 +28,14 @@ type SingleWindow = Window & InjectedWindow & {
SingleSource: SingleSource 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 // add a compat interface of SingleSource to window.injectedWeb3
function injectSingleSource (win: SingleWindow): void { function injectSingleSource (win: SingleWindow): void {
let accounts: Array<InjectedAccount> = []; 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 // 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 // accounts and store them, any get will resolve the last found values
win.SingleSource.accounts$.subscribe((_accounts) => { win.SingleSource.accounts$.subscribe((_accounts) => {
accounts = _accounts.map(({ address, name }) => ({ accounts = transformAccounts(_accounts);
address,
name
}));
}); });
// decorate the compat interface // decorate the compat interface
win.injectedWeb3['SingleSource'] = { win.injectedWeb3['SingleSource'] = {
enable: async (origin: string) => ({ enable: async (origin: string) => ({
accounts: { 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 signer: win.SingleSource.signer
}), }),

View File

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

View File

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

View File

@ -2,14 +2,24 @@
// This software may be modified and distributed under the terms // This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details. // 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 { MessageTypes, MessageAuthorize, MessageExtrinsicSign, MessageExtrinsicSign$Response } from '../types';
import keyring from '@polkadot/ui-keyring'; import keyring from '@polkadot/ui-keyring';
import accountsObservable from '@polkadot/ui-keyring/observable/accounts'; import accountsObservable from '@polkadot/ui-keyring/observable/accounts';
import { assert } from '@polkadot/util'; import { assert, isFunction } from '@polkadot/util';
import State from './State'; 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 { export default class Tabs {
private _state: State; private _state: State;
@ -21,12 +31,22 @@ export default class Tabs {
return this._state.authorizeUrl(url, request); return this._state.authorizeUrl(url, request);
} }
private accountsList (url: string): Array<{ address: string, name?: string }> { private accountsList (url: string): Accounts {
return Object return transformAccounts(accountsObservable.subject.getValue());
.values(accountsObservable.subject.getValue()) }
.map(({ json: { address, meta: { name } } }) => ({
address, name 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> { private extrinsicSign (url: string, request: MessageExtrinsicSign): Promise<MessageExtrinsicSign$Response> {
@ -38,7 +58,7 @@ export default class Tabs {
return this._state.signQueue(url, request); 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) { switch (type) {
case 'authorize.tab': case 'authorize.tab':
return this.authorize(url, request); return this.authorize(url, request);
@ -46,6 +66,9 @@ export default class Tabs {
case 'accounts.list': case 'accounts.list':
return this.accountsList(url); return this.accountsList(url);
case 'accounts.subscribe':
return this.accountsSubscribe(url, subscriber);
case 'extrinsic.sign': case 'extrinsic.sign':
return this.extrinsicSign(url, request); return this.extrinsicSign(url, request);

View File

@ -9,38 +9,54 @@ import Extension from './Extension';
import State from './State'; import State from './State';
import Tabs from './Tabs'; import Tabs from './Tabs';
const FALLBACK_URL = '<unknown>';
const state = new State(); const state = new State();
const extension = new Extension(state); const extension = new Extension(state);
const tabs = new Tabs(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 isPopup = port.name === PORT_POPUP;
const sender = port.sender as chrome.runtime.MessageSender; const sender = port.sender as chrome.runtime.MessageSender;
const from = isPopup const from = isPopup
? 'popup' ? 'popup'
: sender.tab : sender.tab && sender.tab.url;
? sender.tab.url const source = `${from || FALLBACK_URL}: ${id}: ${message}`;
: '<unknown>';
const source = `${from}: ${id}: ${message}`;
console.log(` [in] ${source}`); // :: ${JSON.stringify(request)}`); 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 const promise = isPopup
? extension.handle(message, request) ? extension.handle(message, request)
: tabs.handle(message, request, from); : tabs.handle(message, request, from || FALLBACK_URL, subscription);
promise promise
.then((response) => { .then((response) => {
console.log(`[out] ${source}`); // :: ${JSON.stringify(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) => { .catch((error) => {
console.log(`[err] ${source}:: ${error.message}`); console.log(`[err] ${source}:: ${error.message}`);
port.postMessage({ id, error: 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'; 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]; export type AuthorizeRequest = [number, MessageAuthorize, string];
@ -31,7 +31,8 @@ export type MessageRequest = {
export type MessageResponse = { export type MessageResponse = {
error?: string, error?: string,
id: number, id: number,
response?: any response?: any,
subscription?: any
}; };
export type MessageAccountCreate = { export type MessageAccountCreate = {

View File

@ -2,7 +2,7 @@
// This software may be modified and distributed under the terms // This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details. // 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'; import { SendRequest } from './types';
let sendRequest: SendRequest; let sendRequest: SendRequest;
@ -15,4 +15,13 @@ export default class Accounts implements InjectedAccounts {
get (): Promise<Array<InjectedAccount>> { get (): Promise<Array<InjectedAccount>> {
return sendRequest('accounts.list'); 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'; 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 // - create an event - this we send to the loader
// - the loader takes this evend and uses sendMessage to the extension background // - the loader takes this event and uses port.postMessage to background
// - on respoinse, the loader creates a reponse event // - on resposnse, the loader creates a reponse event
// - this injector, reads listends on the events, maps it to the original // - this injector, listens on the events, maps it to the original
// - resolves/rejects the promise with the result // - resolves/rejects the promise with the result (or sub data)
type Handlers = { type Handlers = {
[index: number]: { [index: number]: {
resolve: (data: any) => void, 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 // 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) // 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) => { return new Promise((resolve, reject) => {
const id = ++idCounter; const id = ++idCounter;
handlers[id] = { resolve, reject }; handlers[id] = { resolve, reject, subscriber };
window.postMessage({ id, message, origin: 'page', request }, '*'); window.postMessage({ id, message, origin: 'page', request }, '*');
}); });
@ -59,7 +60,12 @@ window.addEventListener('message', ({ data, source }) => {
return; return;
} }
delete handlers[data.id]; if (!handler.subscriber) {
delete handlers[data.id];
} else if (data.subscription) {
handler.subscriber(data.subscription);
return;
}
if (data.error) { if (data.error) {
handler.reject(new Error(data.error)); handler.reject(new Error(data.error));

View File

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