Dapp refresh (#5752)

* RwLock

* getting there

* argh

* parking_lot

* rpc

* wax on wax off

* almost there

* remove lock

* write over read

* works

* linting

* small updates

* dissapearing act

* router update

* complete

* one m

* grumbles1

* grumbles part II

* parking_lot->util

* missed test case

* fied package-lock.json

* small fixes

* 404 tests failing

* cleanup

* cleanup 2

* updates and the likes

* play

* simplify filter

* f-ing bugs

* read->write

* Address own grumbles.

* Fix test.
This commit is contained in:
Craig O'Connor 2017-08-09 11:06:40 -06:00 committed by Gav Wood
parent d6eb053826
commit 7d17d77254
18 changed files with 157 additions and 56 deletions

View File

@ -14,7 +14,6 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>. // along with Parity. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
@ -30,8 +29,8 @@ use {WebProxyTokens, ParentFrameSettings};
mod app; mod app;
mod cache; mod cache;
mod fs;
mod ui; mod ui;
pub mod fs;
pub mod fetcher; pub mod fetcher;
pub mod manifest; pub mod manifest;
@ -64,9 +63,10 @@ pub fn all_endpoints<F: Fetch>(
web_proxy_tokens: Arc<WebProxyTokens>, web_proxy_tokens: Arc<WebProxyTokens>,
remote: Remote, remote: Remote,
fetch: F, fetch: F,
) -> Endpoints { ) -> (Vec<String>, Endpoints) {
// fetch fs dapps at first to avoid overwriting builtins // fetch fs dapps at first to avoid overwriting builtins
let mut pages = fs::local_endpoints(dapps_path, embeddable.clone()); let mut pages = fs::local_endpoints(dapps_path.clone(), embeddable.clone());
let local_endpoints: Vec<String> = pages.keys().cloned().collect();
for path in extra_dapps { for path in extra_dapps {
if let Some((id, endpoint)) = fs::local_endpoint(path.clone(), embeddable.clone()) { if let Some((id, endpoint)) = fs::local_endpoint(path.clone(), embeddable.clone()) {
pages.insert(id, endpoint); pages.insert(id, endpoint);
@ -80,10 +80,10 @@ pub fn all_endpoints<F: Fetch>(
pages.insert("proxy".into(), ProxyPac::boxed(embeddable.clone(), dapps_domain.to_owned())); pages.insert("proxy".into(), ProxyPac::boxed(embeddable.clone(), dapps_domain.to_owned()));
pages.insert(WEB_PATH.into(), Web::boxed(embeddable.clone(), web_proxy_tokens.clone(), remote.clone(), fetch.clone())); pages.insert(WEB_PATH.into(), Web::boxed(embeddable.clone(), web_proxy_tokens.clone(), remote.clone(), fetch.clone()));
Arc::new(pages) (local_endpoints, pages)
} }
fn insert<T : WebApp + Default + 'static>(pages: &mut BTreeMap<String, Box<Endpoint>>, id: &str, embed_at: Embeddable) { fn insert<T : WebApp + Default + 'static>(pages: &mut Endpoints, id: &str, embed_at: Embeddable) {
pages.insert(id.to_owned(), Box::new(match embed_at { pages.insert(id.to_owned(), Box::new(match embed_at {
Embeddable::Yes(address) => PageEndpoint::new_safe_to_embed(T::default(), address), Embeddable::Yes(address) => PageEndpoint::new_safe_to_embed(T::default(), address),
Embeddable::No => PageEndpoint::new(T::default()), Embeddable::No => PageEndpoint::new(T::default()),

View File

@ -16,7 +16,6 @@
//! URL Endpoint traits //! URL Endpoint traits
use std::sync::Arc;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use hyper::{self, server, net}; use hyper::{self, server, net};
@ -39,7 +38,7 @@ pub struct EndpointInfo {
pub icon_url: String, pub icon_url: String,
} }
pub type Endpoints = Arc<BTreeMap<String, Box<Endpoint>>>; pub type Endpoints = BTreeMap<String, Box<Endpoint>>;
pub type Handler = server::Handler<net::HttpStream> + Send; pub type Handler = server::Handler<net::HttpStream> + Send;
pub trait Endpoint : Send + Sync { pub trait Endpoint : Send + Sync {

View File

@ -69,9 +69,11 @@ mod web;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
use std::collections::HashMap;
use std::mem;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::collections::HashMap; use util::RwLock;
use jsonrpc_http_server::{self as http, hyper, Origin}; use jsonrpc_http_server::{self as http, hyper, Origin};
@ -101,31 +103,54 @@ impl<F> WebProxyTokens for F where F: Fn(String) -> Option<Origin> + Send + Sync
} }
/// Current supported endpoints. /// Current supported endpoints.
#[derive(Default, Clone)]
pub struct Endpoints { pub struct Endpoints {
endpoints: endpoint::Endpoints, local_endpoints: Arc<RwLock<Vec<String>>>,
endpoints: Arc<RwLock<endpoint::Endpoints>>,
dapps_path: PathBuf,
embeddable: Option<ParentFrameSettings>,
} }
impl Endpoints { impl Endpoints {
/// Returns a current list of app endpoints. /// Returns a current list of app endpoints.
pub fn list(&self) -> Vec<apps::App> { pub fn list(&self) -> Vec<apps::App> {
self.endpoints.iter().filter_map(|(ref k, ref e)| { self.endpoints.read().iter().filter_map(|(ref k, ref e)| {
e.info().map(|ref info| apps::App::from_info(k, info)) e.info().map(|ref info| apps::App::from_info(k, info))
}).collect() }).collect()
} }
/// Check for any changes in the local dapps folder and update.
pub fn refresh_local_dapps(&self) {
let new_local = apps::fs::local_endpoints(&self.dapps_path, self.embeddable.clone());
let old_local = mem::replace(&mut *self.local_endpoints.write(), new_local.keys().cloned().collect());
let (_, to_remove): (_, Vec<_>) = old_local
.into_iter()
.partition(|k| new_local.contains_key(&k.clone()));
let mut endpoints = self.endpoints.write();
// remove the dead dapps
for k in to_remove {
endpoints.remove(&k);
}
// new dapps to be added
for (k, v) in new_local {
if !endpoints.contains_key(&k) {
endpoints.insert(k, v);
}
}
}
} }
/// Dapps server as `jsonrpc-http-server` request middleware. /// Dapps server as `jsonrpc-http-server` request middleware.
pub struct Middleware { pub struct Middleware {
endpoints: Endpoints,
router: router::Router, router: router::Router,
endpoints: endpoint::Endpoints,
} }
impl Middleware { impl Middleware {
/// Get local endpoints handle. /// Get local endpoints handle.
pub fn endpoints(&self) -> Endpoints { pub fn endpoints(&self) -> &Endpoints {
Endpoints { &self.endpoints
endpoints: self.endpoints.clone(),
}
} }
/// Creates new middleware for UI server. /// Creates new middleware for UI server.
@ -164,8 +189,8 @@ impl Middleware {
); );
Middleware { Middleware {
router: router,
endpoints: Default::default(), endpoints: Default::default(),
router: router,
} }
} }
@ -191,8 +216,8 @@ impl Middleware {
remote.clone(), remote.clone(),
fetch.clone(), fetch.clone(),
).embeddable_on(embeddable.clone()).allow_dapps(true)); ).embeddable_on(embeddable.clone()).allow_dapps(true));
let endpoints = apps::all_endpoints( let (local_endpoints, endpoints) = apps::all_endpoints(
dapps_path, dapps_path.clone(),
extra_dapps, extra_dapps,
dapps_domain, dapps_domain,
embeddable.clone(), embeddable.clone(),
@ -200,6 +225,12 @@ impl Middleware {
remote.clone(), remote.clone(),
fetch.clone(), fetch.clone(),
); );
let endpoints = Endpoints {
endpoints: Arc::new(RwLock::new(endpoints)),
dapps_path,
local_endpoints: Arc::new(RwLock::new(local_endpoints)),
embeddable: embeddable.clone(),
};
let special = { let special = {
let mut special = special_endpoints( let mut special = special_endpoints(
@ -225,8 +256,8 @@ impl Middleware {
); );
Middleware { Middleware {
router: router, endpoints,
endpoints: endpoints, router,
} }
} }
} }

View File

@ -28,7 +28,8 @@ use jsonrpc_http_server as http;
use apps; use apps;
use apps::fetcher::Fetcher; use apps::fetcher::Fetcher;
use endpoint::{Endpoint, Endpoints, EndpointPath, Handler}; use endpoint::{Endpoint, EndpointPath, Handler};
use Endpoints;
use handlers; use handlers;
use Embeddable; use Embeddable;
@ -50,26 +51,27 @@ pub struct Router {
dapps_domain: String, dapps_domain: String,
} }
impl http::RequestMiddleware for Router { impl Router {
fn on_request(&self, req: &server::Request<HttpStream>, control: &Control) -> http::RequestMiddlewareAction { fn resolve_request(&self, req: &server::Request<HttpStream>, control: Control, refresh_dapps: bool) -> (bool, Option<Box<Handler>>) {
// Choose proper handler depending on path / domain // Choose proper handler depending on path / domain
let url = handlers::extract_url(req); let url = handlers::extract_url(req);
let endpoint = extract_endpoint(&url, &self.dapps_domain); let endpoint = extract_endpoint(&url, &self.dapps_domain);
let referer = extract_referer_endpoint(req, &self.dapps_domain); let referer = extract_referer_endpoint(req, &self.dapps_domain);
let is_utils = endpoint.1 == SpecialEndpoint::Utils; let is_utils = endpoint.1 == SpecialEndpoint::Utils;
let is_origin_set = req.headers().get::<header::Origin>().is_some();
let is_get_request = *req.method() == hyper::Method::Get; let is_get_request = *req.method() == hyper::Method::Get;
let is_head_request = *req.method() == hyper::Method::Head; let is_head_request = *req.method() == hyper::Method::Head;
let has_dapp = |dapp: &str| self.endpoints
.as_ref()
.map_or(false, |endpoints| endpoints.endpoints.read().contains_key(dapp));
trace!(target: "dapps", "Routing request to {:?}. Details: {:?}", url, req); trace!(target: "dapps", "Routing request to {:?}. Details: {:?}", url, req);
let control = control.clone();
debug!(target: "dapps", "Handling endpoint request: {:?}", endpoint); debug!(target: "dapps", "Handling endpoint request: {:?}", endpoint);
let handler: Option<Box<Handler>> = match (endpoint.0, endpoint.1, referer) {
(is_utils, match (endpoint.0, endpoint.1, referer) {
// Handle invalid web requests that we can recover from // Handle invalid web requests that we can recover from
(ref path, SpecialEndpoint::None, Some((ref referer, ref referer_url))) (ref path, SpecialEndpoint::None, Some((ref referer, ref referer_url)))
if referer.app_id == apps::WEB_PATH if referer.app_id == apps::WEB_PATH
&& self.endpoints.as_ref().map(|ep| ep.contains_key(apps::WEB_PATH)).unwrap_or(false) && has_dapp(apps::WEB_PATH)
&& !is_web_endpoint(path) && !is_web_endpoint(path)
=> =>
{ {
@ -88,11 +90,13 @@ impl http::RequestMiddleware for Router {
.map(|special| special.to_async_handler(path.clone().unwrap_or_default(), control)) .map(|special| special.to_async_handler(path.clone().unwrap_or_default(), control))
}, },
// Then delegate to dapp // Then delegate to dapp
(Some(ref path), _, _) if self.endpoints.as_ref().map(|ep| ep.contains_key(&path.app_id)).unwrap_or(false) => { (Some(ref path), _, _) if has_dapp(&path.app_id) => {
trace!(target: "dapps", "Resolving to local/builtin dapp."); trace!(target: "dapps", "Resolving to local/builtin dapp.");
Some(self.endpoints Some(self.endpoints
.as_ref() .as_ref()
.expect("endpoints known to be set; qed") .expect("endpoints known to be set; qed")
.endpoints
.read()
.get(&path.app_id) .get(&path.app_id)
.expect("endpoints known to contain key; qed") .expect("endpoints known to contain key; qed")
.to_async_handler(path.clone(), control)) .to_async_handler(path.clone(), control))
@ -110,13 +114,19 @@ impl http::RequestMiddleware for Router {
=> =>
{ {
trace!(target: "dapps", "Resolving to 404."); trace!(target: "dapps", "Resolving to 404.");
Some(Box::new(handlers::ContentHandler::error( if refresh_dapps {
hyper::StatusCode::NotFound, debug!(target: "dapps", "Refreshing dapps and re-trying.");
"404 Not Found", self.endpoints.as_ref().map(|endpoints| endpoints.refresh_local_dapps());
"Requested content was not found.", return self.resolve_request(req, control, false)
None, } else {
self.embeddable_on.clone(), Some(Box::new(handlers::ContentHandler::error(
))) hyper::StatusCode::NotFound,
"404 Not Found",
"Requested content was not found.",
None,
self.embeddable_on.clone(),
)))
}
}, },
// Any other GET|HEAD requests to home page. // Any other GET|HEAD requests to home page.
_ if (is_get_request || is_head_request) && self.special.contains_key(&SpecialEndpoint::Home) => { _ if (is_get_request || is_head_request) && self.special.contains_key(&SpecialEndpoint::Home) => {
@ -130,8 +140,15 @@ impl http::RequestMiddleware for Router {
trace!(target: "dapps", "Resolving to RPC call."); trace!(target: "dapps", "Resolving to RPC call.");
None None
} }
}; })
}
}
impl http::RequestMiddleware for Router {
fn on_request(&self, req: &server::Request<HttpStream>, control: &Control) -> http::RequestMiddlewareAction {
let control = control.clone();
let is_origin_set = req.headers().get::<header::Origin>().is_some();
let (is_utils, handler) = self.resolve_request(req, control, self.endpoints.is_some());
match handler { match handler {
Some(handler) => http::RequestMiddlewareAction::Respond { Some(handler) => http::RequestMiddlewareAction::Respond {
should_validate_hosts: !is_utils, should_validate_hosts: !is_utils,

View File

@ -39,7 +39,7 @@ fn should_resolve_dapp() {
// then // then
response.assert_status("HTTP/1.1 404 Not Found"); response.assert_status("HTTP/1.1 404 Not Found");
assert_eq!(registrar.calls.lock().len(), 2); assert_eq!(registrar.calls.lock().len(), 4);
assert_security_headers_for_embed(&response.headers); assert_security_headers_for_embed(&response.headers);
} }

View File

@ -204,4 +204,3 @@ fn should_serve_utils() {
assert_eq!(response.body.contains("function(){"), true); assert_eq!(response.body.contains("function(){"), true);
assert_security_headers(&response.headers); assert_security_headers(&response.headers);
} }

View File

@ -241,5 +241,3 @@ impl<F: Fetch> server::Handler<net::HttpStream> for WebHandler<F> {
} }
} }
} }

4
js/package-lock.json generated
View File

@ -7722,7 +7722,7 @@
"minimatch": { "minimatch": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "integrity": "sha1-UWbihkV/AzBgZL5Ul+jbsMPTIIM=",
"requires": { "requires": {
"brace-expansion": "1.1.8" "brace-expansion": "1.1.8"
} }
@ -10081,7 +10081,7 @@
"react-qr-reader": { "react-qr-reader": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/react-qr-reader/-/react-qr-reader-1.1.3.tgz", "resolved": "https://registry.npmjs.org/react-qr-reader/-/react-qr-reader-1.1.3.tgz",
"integrity": "sha512-ruBF8KaSwUW9nbzjO4rA7/HOCGYZuNUz9od7uBRy8SRBi24nwxWWmwa2z8R6vPGDRglA0y2Qk1aVBuC1olTnHw==", "integrity": "sha1-dDmnZvyZPLj17u/HLCnblh1AswI=",
"requires": { "requires": {
"jsqr": "git+https://github.com/JodusNodus/jsQR.git#5ba1acefa1cbb9b2bc92b49f503f2674e2ec212b", "jsqr": "git+https://github.com/JodusNodus/jsQR.git#5ba1acefa1cbb9b2bc92b49f503f2674e2ec212b",
"prop-types": "15.5.10", "prop-types": "15.5.10",

View File

@ -95,6 +95,11 @@ export default class Parity {
.execute('parity_dappsList'); .execute('parity_dappsList');
} }
dappsRefresh () {
return this._transport
.execute('parity_dappsRefresh');
}
dappsUrl () { dappsUrl () {
return this._transport return this._transport
.execute('parity_dappsUrl'); .execute('parity_dappsUrl');

View File

@ -164,6 +164,17 @@ export default {
} }
}, },
dappsRefresh: {
subdoc: SUBDOC_SET,
desc: 'Returns a boolean value upon success and error upon failure',
params: [],
returns: {
type: Boolean,
desc: 'True for success. error details for failure',
example: true
}
},
dappsUrl: { dappsUrl: {
section: SECTION_NODE, section: SECTION_NODE,
desc: 'Returns the hostname and the port of dapps/rpc server, error if not enabled.', desc: 'Returns the hostname and the port of dapps/rpc server, error if not enabled.',

View File

@ -24,7 +24,7 @@ import { connect } from 'react-redux';
import { DappPermissions, DappsVisible } from '~/modals'; import { DappPermissions, DappsVisible } from '~/modals';
import PermissionStore from '~/modals/DappPermissions/store'; import PermissionStore from '~/modals/DappPermissions/store';
import { Actionbar, Button, DappCard, Page, SectionList } from '~/ui'; import { Actionbar, Button, DappCard, Page, SectionList } from '~/ui';
import { LockedIcon, VisibleIcon } from '~/ui/Icons'; import { LockedIcon, RefreshIcon, VisibleIcon } from '~/ui/Icons';
import DappsStore from './dappsStore'; import DappsStore from './dappsStore';
@ -90,6 +90,17 @@ class Dapps extends Component {
/> />
} }
buttons={ [ buttons={ [
<Button
icon={ <RefreshIcon /> }
key='refresh'
label={
<FormattedMessage
id='dapps.button.dapp.refresh'
defaultMessage='refresh'
/>
}
onClick={ this.store.refreshDapps }
/>,
<Button <Button
icon={ <VisibleIcon /> } icon={ <VisibleIcon /> }
key='edit' key='edit'

View File

@ -89,7 +89,7 @@ export default class DappsStore extends EventEmitter {
return Promise return Promise
.all([ .all([
this.fetchBuiltinApps().then((apps) => this.addApps(apps)), this.fetchBuiltinApps().then((apps) => this.addApps(apps)),
this.fetchLocalApps().then((apps) => this.addApps(apps)) this.fetchLocalApps().then((apps) => this.addApps(apps, true))
]); ]);
} }
@ -227,6 +227,20 @@ export default class DappsStore extends EventEmitter {
return this.visibleApps.filter((app) => app.type === 'network'); return this.visibleApps.filter((app) => app.type === 'network');
} }
@action refreshDapps = () => {
const self = this;
self._api.parity.dappsRefresh()
.then((res) => {
if (res === true) {
self.loadAllApps();
}
})
.catch((err) => {
console.log(err);
});
}
@action openModal = () => { @action openModal = () => {
this.modalOpen = true; this.modalOpen = true;
} }
@ -266,7 +280,7 @@ export default class DappsStore extends EventEmitter {
this.displayApps = Object.assign({}, this.displayApps, displayApps); this.displayApps = Object.assign({}, this.displayApps, displayApps);
}; };
@action addApps = (_apps = []) => { @action addApps = (_apps = [], _local = false) => {
transaction(() => { transaction(() => {
const apps = _apps.filter((app) => app); const apps = _apps.filter((app) => app);
@ -277,6 +291,7 @@ export default class DappsStore extends EventEmitter {
this.apps = this.apps this.apps = this.apps
.filter((app) => !app.id || !newAppsIds.includes(app.id)) .filter((app) => !app.id || !newAppsIds.includes(app.id))
.filter((app) => !(app.type === 'local' && _local && apps.indexOf(app) === -1))
.concat(apps || []) .concat(apps || [])
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));

View File

@ -291,7 +291,7 @@ mod server {
pub fn service(middleware: &Option<Middleware>) -> Option<Arc<rpc_apis::DappsService>> { pub fn service(middleware: &Option<Middleware>) -> Option<Arc<rpc_apis::DappsService>> {
middleware.as_ref().map(|m| Arc::new(DappsServiceWrapper { middleware.as_ref().map(|m| Arc::new(DappsServiceWrapper {
endpoints: m.endpoints() endpoints: m.endpoints().clone(),
}) as Arc<rpc_apis::DappsService>) }) as Arc<rpc_apis::DappsService>)
} }
@ -313,5 +313,10 @@ mod server {
}) })
.collect() .collect()
} }
fn refresh_local_dapps(&self) -> bool {
self.endpoints.refresh_local_dapps();
true
}
} }
} }

View File

@ -22,12 +22,6 @@ use v1::types::LocalDapp;
pub trait DappsService: Send + Sync + 'static { pub trait DappsService: Send + Sync + 'static {
/// List available local dapps. /// List available local dapps.
fn list_dapps(&self) -> Vec<LocalDapp>; fn list_dapps(&self) -> Vec<LocalDapp>;
} /// Refresh local dapps list
fn refresh_local_dapps(&self) -> bool;
impl<F> DappsService for F where
F: Fn() -> Vec<LocalDapp> + Send + Sync + 'static
{
fn list_dapps(&self) -> Vec<LocalDapp> {
(*self)()
}
} }

View File

@ -135,6 +135,10 @@ impl<F: Fetch> ParitySet for ParitySetClient<F> {
})) }))
} }
fn dapps_refresh(&self) -> Result<bool, Error> {
self.dapps.as_ref().map(|dapps| dapps.refresh_local_dapps()).ok_or_else(errors::dapps_disabled)
}
fn dapps_list(&self) -> Result<Vec<LocalDapp>, Error> { fn dapps_list(&self) -> Result<Vec<LocalDapp>, Error> {
self.dapps.as_ref().map(|dapps| dapps.list_dapps()).ok_or_else(errors::dapps_disabled) self.dapps.as_ref().map(|dapps| dapps.list_dapps()).ok_or_else(errors::dapps_disabled)
} }

View File

@ -176,6 +176,10 @@ impl<C, M, U, F> ParitySet for ParitySetClient<C, M, U, F> where
})) }))
} }
fn dapps_refresh(&self) -> Result<bool, Error> {
self.dapps.as_ref().map(|dapps| dapps.refresh_local_dapps()).ok_or_else(errors::dapps_disabled)
}
fn dapps_list(&self) -> Result<Vec<LocalDapp>, Error> { fn dapps_list(&self) -> Result<Vec<LocalDapp>, Error> {
self.dapps.as_ref().map(|dapps| dapps.list_dapps()).ok_or_else(errors::dapps_disabled) self.dapps.as_ref().map(|dapps| dapps.list_dapps()).ok_or_else(errors::dapps_disabled)
} }

View File

@ -34,4 +34,8 @@ impl DappsService for TestDappsService {
icon_url: "title.png".into(), icon_url: "title.png".into(),
}] }]
} }
fn refresh_local_dapps(&self) -> bool {
true
}
} }

View File

@ -96,6 +96,10 @@ build_rpc_trait! {
#[rpc(async, name = "parity_hashContent")] #[rpc(async, name = "parity_hashContent")]
fn hash_content(&self, String) -> BoxFuture<H256, Error>; fn hash_content(&self, String) -> BoxFuture<H256, Error>;
/// Returns true if refresh successful, error if unsuccessful or server is disabled.
#[rpc(name = "parity_dappsRefresh")]
fn dapps_refresh(&self) -> Result<bool, Error>;
/// Returns a list of local dapps /// Returns a list of local dapps
#[rpc(name = "parity_dappsList")] #[rpc(name = "parity_dappsList")]
fn dapps_list(&self) -> Result<Vec<LocalDapp>, Error>; fn dapps_list(&self) -> Result<Vec<LocalDapp>, Error>;