зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1583413
- Fetch the Send Tab target list from FxA, not Sync. r=markh,eoger
Instead of using the list of FxA devices from the Sync clients engine, we now fetch the list of Send Tab devices from FxA. This works like this: * `FxAccountsDevice#getDeviceList` has been split up into `recentDeviceList` and `refreshDeviceList`. * `recentDeviceList` synchronously returns the last fetched list, so that consumers like Send Tab can use it right away. * `refreshDeviceList` is asynchronous, and refreshes the last fetched list. Refreshes are limited to once every minute by default, matching the minimum sync interval (Send Tab passes the `ignoreCached` option to override the limit if the user clicks the "refresh" button). Concurrent calls to `refreshDeviceList` are also serialized, to ensure the list is only fetched once. * The list is flagged as stale when a device is connected or disconnected. It's still kept around, but the next call to `refreshDeviceList` will fetch a new list from the server. * The Send Tab UI refreshes FxA devices in the background. Matching FxA devices to Sync client records is best effort; we don't do it if Sync isn't configured or hasn't run yet. This only impacts the fallback case if the target doesn't support FxA commands. Differential Revision: https://phabricator.services.mozilla.com/D47521 --HG-- extra : moz-landing-system : lando
This commit is contained in:
Родитель
51e03623ee
Коммит
397d3a1156
|
@ -57,17 +57,12 @@ var gSync = {
|
|||
));
|
||||
},
|
||||
|
||||
get syncReady() {
|
||||
return Cc["@mozilla.org/weave/service;1"].getService().wrappedJSObject
|
||||
.ready;
|
||||
},
|
||||
|
||||
// Returns true if sync is configured but hasn't loaded or the send tab
|
||||
// targets list isn't ready yet.
|
||||
// Returns true if FxA is configured, but the send tab targets list isn't
|
||||
// ready yet.
|
||||
get sendTabConfiguredAndLoading() {
|
||||
return (
|
||||
UIState.get().status == UIState.STATUS_SIGNED_IN &&
|
||||
(!this.syncReady || !Weave.Service.clientsEngine.hasSyncedThisSession)
|
||||
!fxAccounts.device.recentDeviceList
|
||||
);
|
||||
},
|
||||
|
||||
|
@ -75,14 +70,26 @@ var gSync = {
|
|||
return UIState.get().status == UIState.STATUS_SIGNED_IN;
|
||||
},
|
||||
|
||||
get sendTabTargets() {
|
||||
return Weave.Service.clientsEngine.fxaDevices
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.filter(
|
||||
d =>
|
||||
!d.isCurrentDevice &&
|
||||
(fxAccounts.commands.sendTab.isDeviceCompatible(d) || d.clientRecord)
|
||||
getSendTabTargets() {
|
||||
let targets = [];
|
||||
if (!fxAccounts.device.recentDeviceList) {
|
||||
return targets;
|
||||
}
|
||||
for (let d of fxAccounts.device.recentDeviceList) {
|
||||
if (d.isCurrentDevice) {
|
||||
continue;
|
||||
}
|
||||
let clientRecord = Weave.Service.clientsEngine.getClientByFxaDeviceId(
|
||||
d.id
|
||||
);
|
||||
if (clientRecord || fxAccounts.commands.sendTab.isDeviceCompatible(d)) {
|
||||
targets.push({
|
||||
clientRecord,
|
||||
...d,
|
||||
});
|
||||
}
|
||||
}
|
||||
return targets.sort((a, b) => a.name.localeCompare(b.name));
|
||||
},
|
||||
|
||||
_generateNodeGetters() {
|
||||
|
@ -220,6 +227,24 @@ var gSync = {
|
|||
this.updateSyncButtonsTooltip(state);
|
||||
this.updateSyncStatus(state);
|
||||
this.updateFxAPanel(state);
|
||||
// Refresh the device list in the background.
|
||||
this.refreshFxaDevices();
|
||||
},
|
||||
|
||||
async refreshFxaDevices(options) {
|
||||
if (UIState.get().status != UIState.STATUS_SIGNED_IN) {
|
||||
console.info("Skipping device list refresh; not signed in");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Poke FxA to refresh the recent device list. It's safe to call
|
||||
// `refreshDeviceList` multiple times in the background, as it avoids
|
||||
// making new requests if one is already active, and caches the list for
|
||||
// 1 minute by default.
|
||||
await fxAccounts.device.refreshDeviceList(options);
|
||||
} catch (e) {
|
||||
console.error("Refreshing device list failed.", e);
|
||||
}
|
||||
},
|
||||
|
||||
updateSendToDeviceTitle() {
|
||||
|
@ -248,7 +273,10 @@ var gSync = {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.sendTabConfiguredAndLoading || this.sendTabTargets.length <= 0) {
|
||||
const targets = this.sendTabConfiguredAndLoading
|
||||
? []
|
||||
: this.getSendTabTargets();
|
||||
if (!targets.length) {
|
||||
PanelUI.showSubView("PanelUI-fxa-menu-sendtab-no-devices", anchor);
|
||||
return;
|
||||
}
|
||||
|
@ -308,13 +336,14 @@ var gSync = {
|
|||
);
|
||||
|
||||
bodyNode.removeAttribute("state");
|
||||
// In the first ~10 sec after startup, Sync may not be loaded and the list
|
||||
// of devices will be empty.
|
||||
// If the app just started, we won't have fetched the device list yet. Sync
|
||||
// does this automatically ~10 sec after startup, but there's no trigger for
|
||||
// this if we're signed in to FxA, but not Sync.
|
||||
if (gSync.sendTabConfiguredAndLoading) {
|
||||
bodyNode.setAttribute("state", "notready");
|
||||
}
|
||||
if (reloadDevices && UIState.get().syncEnabled) {
|
||||
// Force a background Sync
|
||||
if (reloadDevices) {
|
||||
if (UIState.get().syncEnabled) {
|
||||
Services.tm.dispatchToMainThread(async () => {
|
||||
// `engines: []` = clients engine only + refresh FxA Devices.
|
||||
await Weave.Service.sync({ why: "pageactions", engines: [] });
|
||||
|
@ -322,6 +351,15 @@ var gSync = {
|
|||
this.populateSendTabToDevicesView(panelViewNode, false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Force a refresh, since the user probably connected a new device, and
|
||||
// is waiting for it to show up.
|
||||
this.refreshFxaDevices({ ignoreCached: true }).then(_ => {
|
||||
if (!window.closed) {
|
||||
this.populateSendTabToDevicesView(panelViewNode, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -806,8 +844,10 @@ var gSync = {
|
|||
|
||||
const state = UIState.get();
|
||||
if (state.status == UIState.STATUS_SIGNED_IN) {
|
||||
if (this.sendTabTargets.length) {
|
||||
const targets = this.getSendTabTargets();
|
||||
if (targets.length) {
|
||||
this._appendSendTabDeviceList(
|
||||
targets,
|
||||
fragment,
|
||||
createDeviceNodeFn,
|
||||
url,
|
||||
|
@ -829,18 +869,14 @@ var gSync = {
|
|||
devicesPopup.appendChild(fragment);
|
||||
},
|
||||
|
||||
// TODO: once our transition from the old-send tab world is complete,
|
||||
// this list should be built using the FxA device list instead of the client
|
||||
// collection.
|
||||
_appendSendTabDeviceList(
|
||||
targets,
|
||||
fragment,
|
||||
createDeviceNodeFn,
|
||||
url,
|
||||
title,
|
||||
multiselected
|
||||
) {
|
||||
const targets = this.sendTabTargets;
|
||||
|
||||
let tabsToSend = multiselected
|
||||
? gBrowser.selectedTabs.map(t => {
|
||||
return {
|
||||
|
@ -1341,6 +1377,7 @@ var gSync = {
|
|||
},
|
||||
|
||||
onClientsSynced() {
|
||||
// Note that this element is only shown if Sync is enabled.
|
||||
let element = document.getElementById("PanelUI-remotetabs-main");
|
||||
if (element) {
|
||||
if (Weave.Service.clientsEngine.stats.numClients > 1) {
|
||||
|
|
|
@ -307,7 +307,7 @@ add_task(async function sendToDevice_syncNotReady_other_states() {
|
|||
await BrowserTestUtils.withNewTab("http://example.com/", async () => {
|
||||
await promiseSyncReady();
|
||||
const sandbox = sinon.createSandbox();
|
||||
sandbox.stub(gSync, "syncReady").get(() => false);
|
||||
sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => null);
|
||||
sandbox
|
||||
.stub(UIState, "get")
|
||||
.returns({ status: UIState.STATUS_NOT_VERIFIED });
|
||||
|
@ -366,17 +366,22 @@ add_task(async function sendToDevice_syncNotReady_configured() {
|
|||
await BrowserTestUtils.withNewTab("http://example.com/", async () => {
|
||||
await promiseSyncReady();
|
||||
const sandbox = sinon.createSandbox();
|
||||
const syncReady = sandbox.stub(gSync, "syncReady").get(() => false);
|
||||
const hasSyncedThisSession = sandbox
|
||||
.stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
|
||||
.get(() => false);
|
||||
const recentDeviceList = sandbox
|
||||
.stub(fxAccounts.device, "recentDeviceList")
|
||||
.get(() => null);
|
||||
sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
|
||||
sandbox.stub(gSync, "isSendableURI").returns(true);
|
||||
|
||||
sandbox.stub(Weave.Service, "sync").callsFake(() => {
|
||||
syncReady.get(() => true);
|
||||
hasSyncedThisSession.get(() => true);
|
||||
sandbox.stub(gSync, "sendTabTargets").get(() => mockTargets);
|
||||
sandbox.stub(fxAccounts.device, "refreshDeviceList").callsFake(() => {
|
||||
recentDeviceList.get(() =>
|
||||
mockTargets.map(({ id, name, type }) => ({ id, name, type }))
|
||||
);
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
|
||||
.callsFake(fxaDeviceId => {
|
||||
let target = mockTargets.find(c => c.id == fxaDeviceId);
|
||||
return target ? target.clientRecord : null;
|
||||
});
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "getClientType")
|
||||
.callsFake(
|
||||
|
@ -521,13 +526,16 @@ add_task(async function sendToDevice_noDevices() {
|
|||
await BrowserTestUtils.withNewTab("http://example.com/", async () => {
|
||||
await promiseSyncReady();
|
||||
const sandbox = sinon.createSandbox();
|
||||
sandbox.stub(gSync, "syncReady").get(() => true);
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
|
||||
.get(() => true);
|
||||
sandbox.stub(Weave.Service.clientsEngine, "fxaDevices").get(() => []);
|
||||
sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => []);
|
||||
sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
|
||||
sandbox.stub(gSync, "isSendableURI").returns(true);
|
||||
sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
|
||||
.callsFake(fxaDeviceId => {
|
||||
let target = mockTargets.find(c => c.id == fxaDeviceId);
|
||||
return target ? target.clientRecord : null;
|
||||
});
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "getClientType")
|
||||
.callsFake(
|
||||
|
@ -596,13 +604,22 @@ add_task(async function sendToDevice_devices() {
|
|||
await BrowserTestUtils.withNewTab("http://example.com/", async () => {
|
||||
await promiseSyncReady();
|
||||
const sandbox = sinon.createSandbox();
|
||||
sandbox.stub(gSync, "syncReady").get(() => true);
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
|
||||
.get(() => true);
|
||||
.stub(fxAccounts.device, "recentDeviceList")
|
||||
.get(() => mockTargets.map(({ id, name, type }) => ({ id, name, type })));
|
||||
sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
|
||||
sandbox.stub(gSync, "isSendableURI").returns(true);
|
||||
sandbox.stub(gSync, "sendTabTargets").get(() => mockTargets);
|
||||
sandbox
|
||||
.stub(fxAccounts.commands.sendTab, "isDeviceCompatible")
|
||||
.returns(true);
|
||||
sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
|
||||
sandbox.spy(Weave.Service, "sync");
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
|
||||
.callsFake(fxaDeviceId => {
|
||||
let target = mockTargets.find(c => c.id == fxaDeviceId);
|
||||
return target ? target.clientRecord : null;
|
||||
});
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "getClientType")
|
||||
.callsFake(
|
||||
|
@ -636,23 +653,129 @@ add_task(async function sendToDevice_devices() {
|
|||
display: "none",
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
for (let target of mockTargets) {
|
||||
expectedItems.push({
|
||||
{
|
||||
attrs: {
|
||||
clientId: target.id,
|
||||
label: target.name,
|
||||
clientType: target.type,
|
||||
clientId: "1",
|
||||
label: "bar",
|
||||
clientType: "desktop",
|
||||
},
|
||||
});
|
||||
}
|
||||
expectedItems.push(null, {
|
||||
},
|
||||
{
|
||||
attrs: {
|
||||
clientId: "2",
|
||||
label: "baz",
|
||||
clientType: "phone",
|
||||
},
|
||||
},
|
||||
{
|
||||
attrs: {
|
||||
clientId: "0",
|
||||
label: "foo",
|
||||
clientType: "phone",
|
||||
},
|
||||
},
|
||||
{
|
||||
attrs: {
|
||||
clientId: "3",
|
||||
label: "no client record device",
|
||||
clientType: "phone",
|
||||
},
|
||||
},
|
||||
null,
|
||||
{
|
||||
attrs: {
|
||||
label: "Send to All Devices",
|
||||
},
|
||||
});
|
||||
},
|
||||
];
|
||||
checkSendToDeviceItems(expectedItems);
|
||||
|
||||
Assert.ok(Weave.Service.sync.notCalled);
|
||||
|
||||
// Done, hide the panel.
|
||||
let hiddenPromise = promisePageActionPanelHidden();
|
||||
BrowserPageActions.panelNode.hidePopup();
|
||||
await hiddenPromise;
|
||||
|
||||
cleanUp();
|
||||
});
|
||||
});
|
||||
|
||||
add_task(async function sendTabToDevice_syncEnabled() {
|
||||
// Open a tab that's sendable.
|
||||
await BrowserTestUtils.withNewTab("http://example.com/", async () => {
|
||||
await promiseSyncReady();
|
||||
const sandbox = sinon.createSandbox();
|
||||
sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => []);
|
||||
sandbox
|
||||
.stub(UIState, "get")
|
||||
.returns({ status: UIState.STATUS_SIGNED_IN, syncEnabled: true });
|
||||
sandbox.stub(gSync, "isSendableURI").returns(true);
|
||||
sandbox.spy(fxAccounts.device, "refreshDeviceList");
|
||||
sandbox.spy(Weave.Service, "sync");
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
|
||||
.callsFake(fxaDeviceId => {
|
||||
let target = mockTargets.find(c => c.id == fxaDeviceId);
|
||||
return target ? target.clientRecord : null;
|
||||
});
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "getClientType")
|
||||
.callsFake(
|
||||
id =>
|
||||
mockTargets.find(c => c.clientRecord && c.clientRecord.id == id)
|
||||
.clientRecord.type
|
||||
);
|
||||
|
||||
let cleanUp = () => {
|
||||
sandbox.restore();
|
||||
};
|
||||
registerCleanupFunction(cleanUp);
|
||||
|
||||
// Open the panel.
|
||||
await promisePageActionPanelOpen();
|
||||
let sendToDeviceButton = document.getElementById(
|
||||
"pageAction-panel-sendToDevice"
|
||||
);
|
||||
Assert.ok(!sendToDeviceButton.disabled);
|
||||
|
||||
// Click Send to Device.
|
||||
let viewPromise = promisePageActionViewShown();
|
||||
EventUtils.synthesizeMouseAtCenter(sendToDeviceButton, {});
|
||||
let view = await viewPromise;
|
||||
Assert.equal(view.id, "pageAction-panel-sendToDevice-subview");
|
||||
|
||||
let expectedItems = [
|
||||
{
|
||||
className: "pageAction-sendToDevice-notReady",
|
||||
display: "none",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
attrs: {
|
||||
label: "No Devices Connected",
|
||||
},
|
||||
disabled: true,
|
||||
},
|
||||
null,
|
||||
{
|
||||
attrs: {
|
||||
label: "Connect Another Device...",
|
||||
},
|
||||
},
|
||||
{
|
||||
attrs: {
|
||||
label: "Learn About Sending Tabs...",
|
||||
},
|
||||
},
|
||||
];
|
||||
checkSendToDeviceItems(expectedItems);
|
||||
|
||||
Assert.ok(
|
||||
Weave.Service.sync.calledWith({ why: "pageactions", engines: [] })
|
||||
);
|
||||
Assert.ok(fxAccounts.device.refreshDeviceList.notCalled);
|
||||
|
||||
// Done, hide the panel.
|
||||
let hiddenPromise = promisePageActionPanelHidden();
|
||||
BrowserPageActions.panelNode.hidePopup();
|
||||
|
@ -670,15 +793,18 @@ add_task(async function sendToDevice_title() {
|
|||
await BrowserTestUtils.withNewTab("http://example.com/b", async () => {
|
||||
await promiseSyncReady();
|
||||
const sandbox = sinon.createSandbox();
|
||||
sandbox.stub(gSync, "syncReady").get(() => true);
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
|
||||
.get(() => true);
|
||||
sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => []);
|
||||
sandbox
|
||||
.stub(UIState, "get")
|
||||
.returns({ status: UIState.STATUS_SIGNED_IN });
|
||||
sandbox.stub(gSync, "isSendableURI").returns(true);
|
||||
sandbox.stub(gSync, "sendTabTargets").get(() => []);
|
||||
sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
|
||||
.callsFake(fxaDeviceId => {
|
||||
let target = mockTargets.find(c => c.id == fxaDeviceId);
|
||||
return target ? target.clientRecord : null;
|
||||
});
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "getClientType")
|
||||
.callsFake(
|
||||
|
@ -745,14 +871,21 @@ add_task(async function sendToDevice_inUrlbar() {
|
|||
await BrowserTestUtils.withNewTab("http://example.com/", async () => {
|
||||
await promiseSyncReady();
|
||||
const sandbox = sinon.createSandbox();
|
||||
sandbox.stub(gSync, "syncReady").get(() => true);
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
|
||||
.get(() => true);
|
||||
.stub(fxAccounts.device, "recentDeviceList")
|
||||
.get(() => mockTargets.map(({ id, name, type }) => ({ id, name, type })));
|
||||
sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
|
||||
sandbox.stub(gSync, "isSendableURI").returns(true);
|
||||
sandbox.stub(gSync, "sendTabTargets").get(() => mockTargets);
|
||||
sandbox.stub(gSync, "sendTabToDevice").resolves(true);
|
||||
sandbox
|
||||
.stub(fxAccounts.commands.sendTab, "isDeviceCompatible")
|
||||
.returns(true);
|
||||
sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
|
||||
.callsFake(fxaDeviceId => {
|
||||
let target = mockTargets.find(c => c.id == fxaDeviceId);
|
||||
return target ? target.clientRecord : null;
|
||||
});
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "getClientType")
|
||||
.callsFake(
|
||||
|
@ -760,6 +893,7 @@ add_task(async function sendToDevice_inUrlbar() {
|
|||
mockTargets.find(c => c.clientRecord && c.clientRecord.id == id)
|
||||
.clientRecord.type
|
||||
);
|
||||
sandbox.stub(gSync, "sendTabToDevice").resolves(true);
|
||||
|
||||
let cleanUp = () => {
|
||||
sandbox.restore();
|
||||
|
@ -796,21 +930,41 @@ add_task(async function sendToDevice_inUrlbar() {
|
|||
display: "none",
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
for (let target of mockTargets) {
|
||||
expectedItems.push({
|
||||
{
|
||||
attrs: {
|
||||
clientId: target.id,
|
||||
label: target.name,
|
||||
clientType: target.type,
|
||||
clientId: "1",
|
||||
label: "bar",
|
||||
clientType: "desktop",
|
||||
},
|
||||
});
|
||||
}
|
||||
expectedItems.push(null, {
|
||||
},
|
||||
{
|
||||
attrs: {
|
||||
clientId: "2",
|
||||
label: "baz",
|
||||
clientType: "phone",
|
||||
},
|
||||
},
|
||||
{
|
||||
attrs: {
|
||||
clientId: "0",
|
||||
label: "foo",
|
||||
clientType: "phone",
|
||||
},
|
||||
},
|
||||
{
|
||||
attrs: {
|
||||
clientId: "3",
|
||||
label: "no client record device",
|
||||
clientType: "phone",
|
||||
},
|
||||
},
|
||||
null,
|
||||
{
|
||||
attrs: {
|
||||
label: "Send to All Devices",
|
||||
},
|
||||
});
|
||||
},
|
||||
];
|
||||
checkSendToDeviceItems(expectedItems, true);
|
||||
|
||||
// Get the first device menu item in the panel.
|
||||
|
|
|
@ -15,8 +15,15 @@ const fxaDevices = [
|
|||
|
||||
add_task(async function setup() {
|
||||
await promiseSyncReady();
|
||||
await Services.search.init();
|
||||
// gSync.init() is called in a requestIdleCallback. Force its initialization.
|
||||
gSync.init();
|
||||
sinon
|
||||
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
|
||||
.callsFake(fxaDeviceId => {
|
||||
let target = fxaDevices.find(c => c.id == fxaDeviceId);
|
||||
return target ? target.clientRecord : null;
|
||||
});
|
||||
sinon.stub(Weave.Service.clientsEngine, "getClientType").returns("desktop");
|
||||
await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla");
|
||||
});
|
||||
|
@ -336,6 +343,7 @@ add_task(async function test_page_contextmenu_fxa_disabled() {
|
|||
// However, browser_contextmenu.js contains tests that verify its presence.
|
||||
|
||||
add_task(async function teardown() {
|
||||
Weave.Service.clientsEngine.getClientByFxaDeviceId.restore();
|
||||
Weave.Service.clientsEngine.getClientType.restore();
|
||||
gBrowser.removeCurrentTab();
|
||||
});
|
||||
|
|
|
@ -38,8 +38,15 @@ function updateTabContextMenu(tab = gBrowser.selectedTab) {
|
|||
|
||||
add_task(async function setup() {
|
||||
await promiseSyncReady();
|
||||
await Services.search.init();
|
||||
// gSync.init() is called in a requestIdleCallback. Force its initialization.
|
||||
gSync.init();
|
||||
sinon
|
||||
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
|
||||
.callsFake(fxaDeviceId => {
|
||||
let target = fxaDevices.find(c => c.id == fxaDeviceId);
|
||||
return target ? target.clientRecord : null;
|
||||
});
|
||||
sinon.stub(Weave.Service.clientsEngine, "getClientType").returns("desktop");
|
||||
await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla");
|
||||
registerCleanupFunction(() => {
|
||||
|
@ -195,6 +202,7 @@ add_task(async function test_tab_contextmenu_fxa_disabled() {
|
|||
});
|
||||
|
||||
add_task(async function teardown() {
|
||||
Weave.Service.clientsEngine.getClientByFxaDeviceId.restore();
|
||||
Weave.Service.clientsEngine.getClientType.restore();
|
||||
});
|
||||
|
||||
|
|
|
@ -8,23 +8,14 @@ function promiseSyncReady() {
|
|||
}
|
||||
|
||||
function setupSendTabMocks({
|
||||
syncReady = true,
|
||||
fxaDevices = null,
|
||||
state = UIState.STATUS_SIGNED_IN,
|
||||
isSendableURI = true,
|
||||
}) {
|
||||
const sandbox = sinon.createSandbox();
|
||||
sandbox.stub(gSync, "syncReady").get(() => syncReady);
|
||||
if (fxaDevices) {
|
||||
// Clone fxaDevices because it gets sorted in-place.
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "fxaDevices")
|
||||
.get(() => [...fxaDevices]);
|
||||
}
|
||||
sandbox
|
||||
.stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
|
||||
.get(() => !!fxaDevices);
|
||||
sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices);
|
||||
sandbox.stub(UIState, "get").returns({ status: state });
|
||||
sandbox.stub(gSync, "isSendableURI").returns(isSendableURI);
|
||||
sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
|
||||
return sandbox;
|
||||
}
|
||||
|
|
|
@ -174,12 +174,11 @@ var AboutProtectionsHandler = {
|
|||
* The login data.
|
||||
*/
|
||||
async getLoginData() {
|
||||
let syncedDevices = [];
|
||||
let hasFxa = false;
|
||||
|
||||
try {
|
||||
if ((hasFxa = await fxAccounts.accountStatus())) {
|
||||
syncedDevices = await fxAccounts.getDeviceList();
|
||||
await fxAccounts.device.refreshDeviceList();
|
||||
}
|
||||
} catch (e) {
|
||||
Cu.reportError("There was an error fetching login data: ", e.message);
|
||||
|
@ -192,7 +191,9 @@ var AboutProtectionsHandler = {
|
|||
return {
|
||||
hasFxa,
|
||||
numLogins: userFacingLogins,
|
||||
numSyncedDevices: syncedDevices.length,
|
||||
numSyncedDevices: fxAccounts.device.recentDeviceList
|
||||
? fxAccounts.device.recentDeviceList.length
|
||||
: 0,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
@ -422,10 +422,6 @@ class FxAccounts {
|
|||
return this._internal.withVerifiedAccountState(func);
|
||||
}
|
||||
|
||||
getDeviceList() {
|
||||
return this._internal.getDeviceList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array listing all the OAuth clients
|
||||
* connected to the authenticated user's account.
|
||||
|
@ -1101,10 +1097,6 @@ FxAccountsInternal.prototype = {
|
|||
.then(result => currentState.resolve(result));
|
||||
},
|
||||
|
||||
getDeviceList() {
|
||||
return this.device.getDeviceList();
|
||||
},
|
||||
|
||||
/*
|
||||
* Reset state such that any previous flow is canceled.
|
||||
*/
|
||||
|
@ -1121,6 +1113,9 @@ FxAccountsInternal.prototype = {
|
|||
if (this._commands) {
|
||||
this._commands = null;
|
||||
}
|
||||
if (this._device) {
|
||||
this._device.reset();
|
||||
}
|
||||
// We "abort" the accountState and assume our caller is about to throw it
|
||||
// away and replace it with a new one.
|
||||
return this.currentAccountState.abort();
|
||||
|
|
|
@ -128,12 +128,19 @@ class FxAccountsCommands {
|
|||
}
|
||||
|
||||
async _handleCommands(messages) {
|
||||
const fxaDevices = await this._fxai.getDeviceList();
|
||||
try {
|
||||
await this._fxai.device.refreshDeviceList();
|
||||
} catch (e) {
|
||||
log.warn("Error refreshing device list", e);
|
||||
}
|
||||
// We debounce multiple incoming tabs so we show a single notification.
|
||||
const tabsReceived = [];
|
||||
for (const { data } of messages) {
|
||||
const { command, payload, sender: senderId } = data;
|
||||
const sender = senderId ? fxaDevices.find(d => d.id == senderId) : null;
|
||||
const sender =
|
||||
senderId && this._fxai.device.recentDeviceList
|
||||
? this._fxai.device.recentDeviceList.find(d => d.id == senderId)
|
||||
: null;
|
||||
if (!sender) {
|
||||
log.warn(
|
||||
"Incoming command is from an unknown device (maybe disconnected?)"
|
||||
|
|
|
@ -14,6 +14,8 @@ const {
|
|||
ERRNO_DEVICE_SESSION_CONFLICT,
|
||||
ERRNO_UNKNOWN_DEVICE,
|
||||
ON_NEW_DEVICE_ID,
|
||||
ON_DEVICE_CONNECTED_NOTIFICATION,
|
||||
ON_DEVICE_DISCONNECTED_NOTIFICATION,
|
||||
} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
|
||||
|
||||
const { DEVICE_TYPE_DESKTOP } = ChromeUtils.import(
|
||||
|
@ -44,9 +46,27 @@ const PREF_DEPRECATED_DEVICE_NAME = "services.sync.client.name";
|
|||
class FxAccountsDevice {
|
||||
constructor(fxai) {
|
||||
this._fxai = fxai;
|
||||
this._deviceListCache = null;
|
||||
|
||||
// The generation avoids a race where we'll cache a stale device list if the
|
||||
// user signs out during a background refresh. It works like this: during a
|
||||
// refresh, we store the current generation, fetch the new list from the
|
||||
// server, and compare the stored generation to the current one. Since we
|
||||
// increment the generation on reset, we know that the fetched list isn't
|
||||
// valid if the generations are different.
|
||||
this._generation = 0;
|
||||
|
||||
// The current version of the device registration, we use this to re-register
|
||||
// devices after we update what we send on device registration.
|
||||
this.DEVICE_REGISTRATION_VERSION = 2;
|
||||
|
||||
// This is to avoid multiple sequential syncs ending up calling
|
||||
// this expensive endpoint multiple times in a row.
|
||||
this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS = 1 * 60 * 1000; // 1 minute
|
||||
|
||||
// Invalidate our cached device list when a device is connected or disconnected.
|
||||
Services.obs.addObserver(this, ON_DEVICE_CONNECTED_NOTIFICATION, true);
|
||||
Services.obs.addObserver(this, ON_DEVICE_DISCONNECTED_NOTIFICATION, true);
|
||||
}
|
||||
|
||||
async getLocalId() {
|
||||
|
@ -175,13 +195,74 @@ class FxAccountsDevice {
|
|||
);
|
||||
}
|
||||
|
||||
getDeviceList() {
|
||||
return this._fxai.withVerifiedAccountState(async state => {
|
||||
let accountData = await state.getUserAccountData();
|
||||
/**
|
||||
* Returns the most recently fetched device list, or `null` if the list
|
||||
* hasn't been fetched yet. This is synchronous, so that consumers like
|
||||
* Send Tab can render the device list right away, without waiting for
|
||||
* it to refresh.
|
||||
*
|
||||
* @type {?Array}
|
||||
*/
|
||||
get recentDeviceList() {
|
||||
return this._deviceListCache ? this._deviceListCache.devices : null;
|
||||
}
|
||||
|
||||
const devices = await this._fxai.fxAccountsClient.getDeviceList(
|
||||
/**
|
||||
* Refreshes the device list. After this function returns, consumers can
|
||||
* access the new list using the `recentDeviceList` getter. Note that
|
||||
* multiple concurrent calls to `refreshDeviceList` will only refresh the
|
||||
* list once.
|
||||
*
|
||||
* @param {Boolean} [options.ignoreCached]
|
||||
* If `true`, forces a refresh, even if the cached device list is
|
||||
* still fresh. Defaults to `false`.
|
||||
* @return {Promise<Boolean>}
|
||||
* `true` if the list was refreshed, `false` if the cached list is
|
||||
* fresh. Rejects if an error occurs refreshing the list or device
|
||||
* push registration.
|
||||
*/
|
||||
async refreshDeviceList({ ignoreCached = false } = {}) {
|
||||
if (this._fetchAndCacheDeviceListPromise) {
|
||||
// If we're already refreshing the list in the background, let that
|
||||
// finish.
|
||||
return this._fetchAndCacheDeviceListPromise;
|
||||
}
|
||||
if (ignoreCached || !this._deviceListCache) {
|
||||
return this._fetchAndCacheDeviceList();
|
||||
}
|
||||
if (
|
||||
this._fxai.now() - this._deviceListCache.lastFetch <
|
||||
this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS
|
||||
) {
|
||||
// If our recent device list is still fresh, skip the request to
|
||||
// refresh it.
|
||||
return false;
|
||||
}
|
||||
return this._fetchAndCacheDeviceList();
|
||||
}
|
||||
|
||||
async _fetchAndCacheDeviceList() {
|
||||
if (this._fetchAndCacheDeviceListPromise) {
|
||||
return this._fetchAndCacheDeviceListPromise;
|
||||
}
|
||||
let generation = this._generation;
|
||||
return (this._fetchAndCacheDeviceListPromise = this._fxai
|
||||
.withVerifiedAccountState(async state => {
|
||||
let accountData = await state.getUserAccountData([
|
||||
"sessionToken",
|
||||
"device",
|
||||
]);
|
||||
|
||||
let devices = await this._fxai.fxAccountsClient.getDeviceList(
|
||||
accountData.sessionToken
|
||||
);
|
||||
if (generation != this._generation) {
|
||||
throw new Error("Another user has signed in");
|
||||
}
|
||||
this._deviceListCache = {
|
||||
lastFetch: this._fxai.now(),
|
||||
devices,
|
||||
};
|
||||
|
||||
// Check if our push registration is still good.
|
||||
const ourDevice = devices.find(device => device.isCurrentDevice);
|
||||
|
@ -189,8 +270,12 @@ class FxAccountsDevice {
|
|||
await this._fxai.fxaPushService.unsubscribe();
|
||||
await this._registerOrUpdateDevice(accountData);
|
||||
}
|
||||
return devices;
|
||||
});
|
||||
|
||||
return true;
|
||||
})
|
||||
.finally(_ => {
|
||||
this._fetchAndCacheDeviceListPromise = null;
|
||||
}));
|
||||
}
|
||||
|
||||
async updateDeviceRegistration() {
|
||||
|
@ -363,8 +448,46 @@ class FxAccountsDevice {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._deviceListCache = null;
|
||||
this._generation++;
|
||||
this._fetchAndCacheDeviceListPromise = null;
|
||||
}
|
||||
|
||||
// Kick off a background refresh when a device is connected or disconnected.
|
||||
observe(subject, topic, data) {
|
||||
switch (topic) {
|
||||
case ON_DEVICE_CONNECTED_NOTIFICATION:
|
||||
this._fetchAndCacheDeviceList().catch(error => {
|
||||
log.warn(
|
||||
"failed to refresh devices after connecting a new device",
|
||||
error
|
||||
);
|
||||
});
|
||||
break;
|
||||
case ON_DEVICE_DISCONNECTED_NOTIFICATION:
|
||||
let json = JSON.parse(data);
|
||||
if (!json.isLocalDevice) {
|
||||
// If we're the device being disconnected, don't bother fetching a new
|
||||
// list, since our session token is now invalid.
|
||||
this._fetchAndCacheDeviceList().catch(error => {
|
||||
log.warn(
|
||||
"failed to refresh devices after disconnecting a device",
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FxAccountsDevice.prototype.QueryInterface = ChromeUtils.generateQI([
|
||||
Ci.nsIObserver,
|
||||
Ci.nsISupportsWeakReference,
|
||||
]);
|
||||
|
||||
function urlsafeBase64Encode(buffer) {
|
||||
return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
|
||||
}
|
||||
|
|
|
@ -9,10 +9,15 @@ const { FxAccounts } = ChromeUtils.import(
|
|||
const { FxAccountsClient } = ChromeUtils.import(
|
||||
"resource://gre/modules/FxAccountsClient.jsm"
|
||||
);
|
||||
const { FxAccountsDevice } = ChromeUtils.import(
|
||||
"resource://gre/modules/FxAccountsDevice.jsm"
|
||||
);
|
||||
const {
|
||||
ERRNO_DEVICE_SESSION_CONFLICT,
|
||||
ERRNO_TOO_MANY_CLIENT_REQUESTS,
|
||||
ERRNO_UNKNOWN_DEVICE,
|
||||
ON_DEVICE_CONNECTED_NOTIFICATION,
|
||||
ON_DEVICE_DISCONNECTED_NOTIFICATION,
|
||||
} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
|
||||
var { AccountState } = ChromeUtils.import(
|
||||
"resource://gre/modules/FxAccounts.jsm",
|
||||
|
@ -635,12 +640,137 @@ add_task(async function test_devicelist_pushendpointexpired() {
|
|||
]);
|
||||
};
|
||||
|
||||
await fxa.getDeviceList();
|
||||
await fxa.device.refreshDeviceList();
|
||||
|
||||
Assert.equal(spy.getDeviceList.count, 1);
|
||||
Assert.equal(spy.updateDevice.count, 1);
|
||||
});
|
||||
|
||||
add_task(async function test_refreshDeviceList() {
|
||||
let credentials = getTestUser("baz");
|
||||
|
||||
let storage = new MockStorageManager();
|
||||
storage.initialize(credentials);
|
||||
let state = new AccountState(storage);
|
||||
|
||||
let fxAccountsClient = new MockFxAccountsClient({
|
||||
id: "deviceAAAAAA",
|
||||
name: "iPhone",
|
||||
type: "phone",
|
||||
sessionToken: credentials.sessionToken,
|
||||
});
|
||||
let spy = {
|
||||
getDeviceList: { count: 0 },
|
||||
};
|
||||
fxAccountsClient.getDeviceList = (function(old) {
|
||||
return function getDeviceList() {
|
||||
spy.getDeviceList.count += 1;
|
||||
return old.apply(this, arguments);
|
||||
};
|
||||
})(fxAccountsClient.getDeviceList);
|
||||
let fxai = {
|
||||
_now: Date.now(),
|
||||
fxAccountsClient,
|
||||
now() {
|
||||
return this._now;
|
||||
},
|
||||
withVerifiedAccountState(func) {
|
||||
// Ensure `func` is called asynchronously.
|
||||
return Promise.resolve().then(_ => func(state));
|
||||
},
|
||||
fxaPushService: null,
|
||||
};
|
||||
let device = new FxAccountsDevice(fxai);
|
||||
|
||||
Assert.equal(
|
||||
device.recentDeviceList,
|
||||
null,
|
||||
"Should not have device list initially"
|
||||
);
|
||||
Assert.ok(await device.refreshDeviceList(), "Should refresh list");
|
||||
Assert.deepEqual(
|
||||
device.recentDeviceList,
|
||||
[
|
||||
{
|
||||
id: "deviceAAAAAA",
|
||||
name: "iPhone",
|
||||
type: "phone",
|
||||
isCurrentDevice: true,
|
||||
},
|
||||
],
|
||||
"Should fetch device list"
|
||||
);
|
||||
Assert.equal(
|
||||
spy.getDeviceList.count,
|
||||
1,
|
||||
"Should make request to refresh list"
|
||||
);
|
||||
Assert.ok(
|
||||
!(await device.refreshDeviceList()),
|
||||
"Should not refresh device list if fresh"
|
||||
);
|
||||
|
||||
fxai._now += device.TIME_BETWEEN_FXA_DEVICES_FETCH_MS;
|
||||
|
||||
let refreshPromise = device.refreshDeviceList();
|
||||
let secondRefreshPromise = device.refreshDeviceList();
|
||||
Assert.ok(
|
||||
await Promise.all([refreshPromise, secondRefreshPromise]),
|
||||
"Should refresh list if stale"
|
||||
);
|
||||
Assert.equal(
|
||||
spy.getDeviceList.count,
|
||||
2,
|
||||
"Should only make one request if called with pending request"
|
||||
);
|
||||
|
||||
device.observe(null, ON_DEVICE_CONNECTED_NOTIFICATION);
|
||||
await device.refreshDeviceList();
|
||||
Assert.equal(
|
||||
spy.getDeviceList.count,
|
||||
3,
|
||||
"Should refresh device list after connecting new device"
|
||||
);
|
||||
device.observe(
|
||||
null,
|
||||
ON_DEVICE_DISCONNECTED_NOTIFICATION,
|
||||
JSON.stringify({ isLocalDevice: false })
|
||||
);
|
||||
await device.refreshDeviceList();
|
||||
Assert.equal(
|
||||
spy.getDeviceList.count,
|
||||
4,
|
||||
"Should refresh device list after disconnecting device"
|
||||
);
|
||||
device.observe(
|
||||
null,
|
||||
ON_DEVICE_DISCONNECTED_NOTIFICATION,
|
||||
JSON.stringify({ isLocalDevice: true })
|
||||
);
|
||||
await device.refreshDeviceList();
|
||||
Assert.equal(
|
||||
spy.getDeviceList.count,
|
||||
4,
|
||||
"Should not refresh device list after disconnecting this device"
|
||||
);
|
||||
|
||||
let refreshBeforeResetPromise = device.refreshDeviceList({
|
||||
ignoreCached: true,
|
||||
});
|
||||
device.reset();
|
||||
await Assert.rejects(refreshBeforeResetPromise, /Another user has signed in/);
|
||||
|
||||
Assert.equal(
|
||||
device.recentDeviceList,
|
||||
null,
|
||||
"Should clear device list after resetting"
|
||||
);
|
||||
Assert.ok(
|
||||
await device.refreshDeviceList(),
|
||||
"Should fetch new list after resetting"
|
||||
);
|
||||
});
|
||||
|
||||
function expandHex(two_hex) {
|
||||
// Return a 64-character hex string, encoding 32 identical bytes.
|
||||
let eight_hex = two_hex + two_hex + two_hex + two_hex;
|
||||
|
|
|
@ -65,10 +65,6 @@ const STALE_CLIENT_REMOTE_AGE = 604800; // 7 days
|
|||
// TTL of the message sent to another device when sending a tab
|
||||
const NOTIFY_TAB_SENT_TTL_SECS = 1 * 3600; // 1 hour
|
||||
|
||||
// This is to avoid multiple sequential syncs ending up calling
|
||||
// this expensive endpoint multiple times in a row.
|
||||
const TIME_BETWEEN_FXA_DEVICES_FETCH_MS = 10 * 1000;
|
||||
|
||||
// Reasons behind sending collection_changed push notifications.
|
||||
const COLLECTION_MODIFIED_REASON_SENDTAB = "sendtab";
|
||||
const COLLECTION_MODIFIED_REASON_FIRSTSYNC = "firstsync";
|
||||
|
@ -159,10 +155,6 @@ ClientEngine.prototype = {
|
|||
Svc.Prefs.set(this.name + ".lastRecordUpload", Math.floor(value));
|
||||
},
|
||||
|
||||
get fxaDevices() {
|
||||
return this._fxaDevices;
|
||||
},
|
||||
|
||||
get remoteClients() {
|
||||
// return all non-stale clients for external consumption.
|
||||
return Object.values(this._store._remoteClients).filter(v => !v.stale);
|
||||
|
@ -259,6 +251,19 @@ ClientEngine.prototype = {
|
|||
return null;
|
||||
},
|
||||
|
||||
getClientByFxaDeviceId(fxaDeviceId) {
|
||||
for (let id in this._store._remoteClients) {
|
||||
let client = this._store._remoteClients[id];
|
||||
if (client.stale) {
|
||||
continue;
|
||||
}
|
||||
if (client.fxaDeviceId == fxaDeviceId) {
|
||||
return client;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
getClientType(id) {
|
||||
const client = this._store._remoteClients[id];
|
||||
if (client.type == DEVICE_TYPE_DESKTOP) {
|
||||
|
@ -387,26 +392,11 @@ ClientEngine.prototype = {
|
|||
},
|
||||
|
||||
async _fetchFxADevices() {
|
||||
const now = new Date().getTime();
|
||||
if (
|
||||
(this._lastFxADevicesFetch || 0) + TIME_BETWEEN_FXA_DEVICES_FETCH_MS >=
|
||||
now
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const remoteClients = Object.values(this.remoteClients);
|
||||
try {
|
||||
this._fxaDevices = await this.fxAccounts.getDeviceList();
|
||||
for (const device of this._fxaDevices) {
|
||||
device.clientRecord = remoteClients.find(
|
||||
c => c.fxaDeviceId == device.id
|
||||
);
|
||||
}
|
||||
await this.fxAccounts.device.refreshDeviceList();
|
||||
} catch (e) {
|
||||
this._log.error("Could not retrieve the FxA device list", e);
|
||||
this._fxaDevices = [];
|
||||
this._log.error("Could not refresh the FxA device list", e);
|
||||
}
|
||||
this._lastFxADevicesFetch = now;
|
||||
|
||||
// We assume that clients not present in the FxA Device Manager list have been
|
||||
// disconnected and so are stale
|
||||
|
@ -414,7 +404,9 @@ ClientEngine.prototype = {
|
|||
let localClients = Object.values(this._store._remoteClients)
|
||||
.filter(client => client.fxaDeviceId) // iOS client records don't have fxaDeviceId
|
||||
.map(client => client.fxaDeviceId);
|
||||
const fxaClients = this._fxaDevices.map(device => device.id);
|
||||
const fxaClients = this.fxAccounts.device.recentDeviceList
|
||||
? this.fxAccounts.device.recentDeviceList.map(device => device.id)
|
||||
: [];
|
||||
this._knownStaleFxADeviceIds = Utils.arraySub(localClients, fxaClients);
|
||||
},
|
||||
|
||||
|
|
|
@ -990,9 +990,10 @@ add_task(async function test_clients_not_in_fxa_list() {
|
|||
getLocalType() {
|
||||
return fxAccounts.device.getLocalType();
|
||||
},
|
||||
recentDeviceList: [{ id: remoteId }],
|
||||
refreshDeviceList() {
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
getDeviceList() {
|
||||
return Promise.resolve([{ id: remoteId }]);
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -1069,9 +1070,10 @@ add_task(async function test_dupe_device_ids() {
|
|||
getLocalType() {
|
||||
return fxAccounts.device.getLocalType();
|
||||
},
|
||||
recentDeviceList: [{ id: remoteDeviceId }],
|
||||
refreshDeviceList() {
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
getDeviceList() {
|
||||
return Promise.resolve([{ id: remoteDeviceId }]);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
Загрузка…
Ссылка в новой задаче