Files
tubestation/waterfox/browser/components/sidebar/common/sync.js
2025-11-06 14:13:52 +00:00

516 lines
15 KiB
JavaScript

/*
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
'use strict';
import EventListenerManager from '/extlib/EventListenerManager.js';
import {
log as internalLogger,
configs,
notify,
wait,
getChunkedConfig,
setChunkedConfig,
isLinux,
} from './common.js';
import * as Constants from '/common/constants.js';
import * as TreeBehavior from '/common/tree-behavior.js';
import { Tab } from '/common/TreeItem.js';
function log(...args) {
internalLogger('common/sync', ...args);
}
export const onMessage = new EventListenerManager();
export const onNewDevice = new EventListenerManager();
export const onUpdatedDevice = new EventListenerManager();
export const onObsoleteDevice = new EventListenerManager();
const SEND_TABS_SIMULATOR_ID = 'send-tabs-to-device-simulator@piro.sakura.ne.jp';
// Workaround for https://github.com/piroor/treestyletab/blob/trunk/README.md#user-content-feature-requests-send-tab-tree-to-device-does-not-work
// and https://bugzilla.mozilla.org/show_bug.cgi?id=1417183 (resolved as WONTFIX)
// Firefox does not provide any API to access to Sync features directly.
// We need to provide it as experiments API or something way.
// This module is designed to work with a service provide which has features:
// * async getOtherDevices()
// - Returns an array of sync devices.
// - Retruned array should have 0 or more items like:
// { id: "identifier of the device",
// name: "name of the device",
// type: "type of the device (desktop, mobile, and so on)" }
// * sendTabsToDevice(tabs, deviceId)
// - Returns nothing.
// - Sends URLs of given tabs to the specified device.
// - The device is one of values returned by getOtherDevices().
// * sendTabsToAllDevices(tabs)
// - Returns nothing.
// - Sends URLs of given tabs to all other devices.
// * manageDevices(windowId)
// - Returns nothing.
// - Opens settings page for Sync devices.
// It will appear as a tab in the specified window.
let mExternalProvider = null;
export function registerExternalProvider(provider) {
mExternalProvider = provider;
}
export function hasExternalProvider() {
return !!mExternalProvider;
}
let mMyDeviceInfo = null;
async function getMyDeviceInfo() {
if (!configs.syncDeviceInfo || !configs.syncDeviceInfo.id) {
const newDeviceInfo = await generateDeviceInfo();
if (!configs.syncDeviceInfo)
configs.syncDeviceInfo = newDeviceInfo;
}
return mMyDeviceInfo = configs.syncDeviceInfo;
}
async function ensureDeviceInfoInitialized() {
await getMyDeviceInfo();
}
export async function waitUntilDeviceInfoInitialized() {
while (!configs.syncDeviceInfo) {
await wait(100);
}
mMyDeviceInfo = configs.syncDeviceInfo;
}
let initialized = false;
let preChanges = [];
function onConfigChanged(key, value = undefined) {
if (!initialized) {
preChanges.push({ key, value: value === undefined ? configs[key] : value });
return;
}
switch (key) {
case 'syncOtherDevicesDetected':
if (!configs.syncAvailableNotified) {
configs.syncAvailableNotified = true;
notify({
title: browser.i18n.getMessage('syncAvailable_notification_title'),
message: browser.i18n.getMessage(`syncAvailable_notification_message${isLinux() ? '_linux' : ''}`),
url: `${Constants.kSHORTHAND_URIS.options}#syncTabsToDeviceOptions`,
timeout: configs.syncAvailableNotificationTimeout
});
}
return;
case 'syncDevices':
// This may happen when all configs are reset.
// We need to try updating devices after syncDeviceInfo is completely cleared.
wait(100).then(updateDevices);
break;
case 'syncDeviceInfo':
if (configs.syncDeviceInfo &&
mMyDeviceInfo &&
configs.syncDeviceInfo.id == mMyDeviceInfo.id &&
configs.syncDeviceInfo.timestamp == mMyDeviceInfo.timestamp)
return; // ignore updating triggered by myself
mMyDeviceInfo = null;
updateSelf();
break;
default:
if (key.startsWith('chunkedSyncData'))
reserveToReceiveMessage();
break;
}
}
browser.runtime.onMessageExternal.addListener((message, sender) => {
if (!initialized ||
!message ||
typeof message != 'object' ||
typeof message.type != 'string' ||
sender.id != SEND_TABS_SIMULATOR_ID)
return;
switch (message.type) {
case 'ready':
try {
browser.runtime.sendMessage(SEND_TABS_SIMULATOR_ID, { type: 'register-self' }).catch(_error => {});
}
catch(_error) {
}
case 'device-added':
case 'device-updated':
case 'device-removed':
updateSelf();
break;
}
});
export async function init() {
configs.$addObserver(onConfigChanged); // we need to register the listener to collect pre-sent changes
await configs.$loaded;
await ensureDeviceInfoInitialized();
await updateSelf();
initialized = true;
reserveToReceiveMessage();
window.setInterval(updateSelf, 1000 * 60 * 60 * 24); // update info every day!
for (const change of preChanges) {
onConfigChanged(change.key, change.value);
}
preChanges = [];
try {
browser.runtime.sendMessage(SEND_TABS_SIMULATOR_ID, { type: 'register-self' }).catch(_error => {});
}
catch(_error) {
}
}
export async function generateDeviceInfo({ name, icon } = {}) {
const [platformInfo, browserInfo] = await Promise.all([
browser.runtime.getPlatformInfo(),
browser.runtime.getBrowserInfo()
]);
return {
id: `device-${Date.now()}-${Math.round(Math.random() * 65000)}`,
name: name === undefined ?
browser.i18n.getMessage('syncDeviceDefaultName', [toHumanReadableOSName(platformInfo.os), browserInfo.name]) :
(name || null),
icon: icon || 'device-desktop'
};
}
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/PlatformOs
function toHumanReadableOSName(os) {
switch (os) {
case 'mac': return 'macOS';
case 'win': return 'Windows';
case 'android': return 'Android';
case 'cros': return 'Chrome OS';
case 'linux': return 'Linux';
case 'openbsd': return 'Open/FreeBSD';
default: return 'Unknown Platform';
}
}
configs.$addObserver(key => {
switch (key) {
case 'syncUnsendableUrlPattern':
isSendableTab.unsendableUrlMatcher = null;
break;
default:
break;
}
});
async function updateSelf() {
if (updateSelf.updating)
return;
updateSelf.updating = true;
const [devices] = await Promise.all([
(async () => {
try {
return await browser.runtime.sendMessage(SEND_TABS_SIMULATOR_ID, { type: 'list-devices' });
}
catch(_error) {
}
})(),
ensureDeviceInfoInitialized(),
]);
if (devices) {
const myDeviceFromSimulator = devices.find(device => device.myself);
if (mMyDeviceInfo.simulatorId != myDeviceFromSimulator.id)
mMyDeviceInfo.simulatorId = myDeviceFromSimulator.id;
}
configs.syncDeviceInfo = mMyDeviceInfo = {
...clone(configs.syncDeviceInfo),
timestamp: Date.now(),
};
await updateDevices();
setTimeout(() => {
updateSelf.updating = false;
}, 250);
}
async function updateDevices() {
if (updateDevices.updating)
return;
updateDevices.updating = true;
const [devicesFromSimulator] = await Promise.all([
(async () => {
try {
return await browser.runtime.sendMessage(SEND_TABS_SIMULATOR_ID, { type: 'list-devices' });
}
catch(_error) {
}
})(),
waitUntilDeviceInfoInitialized(),
]);
const remote = clone(configs.syncDevices);
const local = clone(configs.syncDevicesLocalCache);
log('devices updated: ', local, remote);
for (const [id, info] of Object.entries(remote)) {
if (id == mMyDeviceInfo.id)
continue;
local[id] = info;
if (id in local) {
log('updated device: ', info);
onUpdatedDevice.dispatch(info);
}
else {
log('new device: ', info);
onNewDevice.dispatch(info);
}
}
if (devicesFromSimulator) {
const knownDeviceIdsFromSimulator = new Set(Object.values(local).map(device => device.simulatorId).filter(id => !!id));
for (const deviceFromSimulator of devicesFromSimulator) {
if (knownDeviceIdsFromSimulator.has(deviceFromSimulator.id))
continue;
const localId = `device-from-simulator:${deviceFromSimulator.id}`;
local[localId] = {
...deviceFromSimulator,
id: localId,
};
}
}
for (const [id, info] of Object.entries(local)) {
if (id in remote ||
id == mMyDeviceInfo.id)
continue;
log('obsolete device: ', info);
delete local[id];
onObsoleteDevice.dispatch(info);
}
if (configs.syncDeviceExpirationDays > 0) {
const expireDateInSeconds = Date.now() - (1000 * 60 * 60 * configs.syncDeviceExpirationDays);
for (const [id, info] of Object.entries(local)) {
if (info &&
info.timestamp < expireDateInSeconds) {
delete local[id];
log('expired device: ', info);
onObsoleteDevice.dispatch(info);
}
}
}
local[mMyDeviceInfo.id] = clone(mMyDeviceInfo);
log('store myself: ', mMyDeviceInfo, local[mMyDeviceInfo.id]);
if (!configs.syncOtherDevicesDetected && Object.keys(local).length > 1)
configs.syncOtherDevicesDetected = true;
configs.syncDevices = local;
configs.syncDevicesLocalCache = clone(local);
setTimeout(() => {
updateDevices.updating = false;
}, 250);
}
function reserveToReceiveMessage() {
if (reserveToReceiveMessage.reserved)
clearTimeout(reserveToReceiveMessage.reserved);
reserveToReceiveMessage.reserved = setTimeout(() => {
delete reserveToReceiveMessage.reserved;
receiveMessage();
}, 250);
}
async function receiveMessage() {
const myDeviceInfo = await getMyDeviceInfo();
try {
const messages = readMessages();
log('receiveMessage: queued messages => ', messages);
const restMessages = messages.filter(message => {
if (message.timestamp <= configs.syncLastMessageTimestamp)
return false;
if (message.to == myDeviceInfo.id) {
log('receiveMessage receive: ', message);
configs.syncLastMessageTimestamp = message.timestamp;
onMessage.dispatch(message);
return false;
}
return true;
});
log('receiveMessage: restMessages => ', restMessages);
if (restMessages.length != messages.length)
writeMessages(restMessages);
}
catch(error) {
log('receiveMessage fatal error: ', error);
writeMessages([]);
}
}
export async function sendMessage(to, data) {
const myDeviceInfo = await getMyDeviceInfo();
try {
const messages = readMessages();
messages.push({
timestamp: Date.now(),
from: myDeviceInfo.id,
to,
data
});
log('sendMessage: queued messages => ', messages);
writeMessages(messages);
}
catch(error) {
console.log('Sync.sendMessage: failed to send message ', error);
writeMessages([]);
}
}
function readMessages() {
try {
return uniqMessages([
...JSON.parse(getChunkedConfig('chunkedSyncDataLocal') || '[]'),
...JSON.parse(getChunkedConfig('chunkedSyncData') || '[]')
]);
}
catch(error) {
log('failed to read messages: ', error);
return [];
}
}
function writeMessages(messages) {
const stringified = JSON.stringify(messages || []);
setChunkedConfig('chunkedSyncDataLocal', stringified);
setChunkedConfig('chunkedSyncData', stringified);
}
function uniqMessages(messages) {
const knownMessages = new Set();
return messages.filter(message => {
const key = JSON.stringify(message);
if (knownMessages.has(key))
return false;
knownMessages.add(key);
return true;
});
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
export async function getOtherDevices() {
if (mExternalProvider)
return mExternalProvider.getOtherDevices();
await waitUntilDeviceInfoInitialized();
const devices = configs.syncDevices || {};
const result = [];
for (const [id, info] of Object.entries(devices)) {
if (id == mMyDeviceInfo.id ||
!info.id /* ignore invalid device info accidentally saved (see also https://github.com/piroor/treestyletab/issues/2922 ) */)
continue;
result.push(info);
}
return result.sort((a, b) => a.name > b.name);
}
export function getDeviceName(id) {
const devices = configs.syncDevices || {};
if (!(id in devices) || !devices[id])
return browser.i18n.getMessage('syncDeviceUnknownDevice');
return String(devices[id].name || '').trim() || browser.i18n.getMessage('syncDeviceMissingDeviceName');
}
// https://searchfox.org/mozilla-central/rev/d866b96d74ec2a63f09ee418f048d23f4fd379a2/browser/base/content/browser-sync.js#1176
export function isSendableTab(tab) {
if (!tab.url ||
tab.url.length > 65535)
return false;
if (!isSendableTab.unsendableUrlMatcher)
isSendableTab.unsendableUrlMatcher = new RegExp(configs.syncUnsendableUrlPattern);
return !isSendableTab.unsendableUrlMatcher.test(tab.url);
}
export function sendTabsToDevice(tabs, { to, recursively } = {}) {
if (recursively)
tabs = Tab.collectRootTabs(tabs).map(tab => [tab, ...tab.$TST.descendants]).flat();
tabs = tabs.filter(isSendableTab);
if (mExternalProvider)
return mExternalProvider.sendTabsToDevice(tabs, to);
sendMessage(to, getTabsDataToSend(tabs));
const multiple = tabs.length > 1 ? '_multiple' : '';
notify({
title: browser.i18n.getMessage(
`sentTabs_notification_title${multiple}`,
[getDeviceName(to)]
),
message: browser.i18n.getMessage(
`sentTabs_notification_message${multiple}`,
[getDeviceName(to)]
),
timeout: configs.syncSentTabsNotificationTimeout
});
}
export async function sendTabsToAllDevices(tabs, { recursively } = {}) {
if (recursively)
tabs = Tab.collectRootTabs(tabs).map(tab => [tab, ...tab.$TST.descendants]).flat();
tabs = tabs.filter(isSendableTab);
if (mExternalProvider)
return mExternalProvider.sendTabsToAllDevices(tabs);
const data = getTabsDataToSend(tabs);
const devices = await getOtherDevices();
for (const device of devices) {
sendMessage(device.id, data);
}
const multiple = tabs.length > 1 ? '_multiple' : '';
notify({
title: browser.i18n.getMessage(`sentTabsToAllDevices_notification_title${multiple}`),
message: browser.i18n.getMessage(`sentTabsToAllDevices_notification_message${multiple}`),
timeout: configs.syncSentTabsNotificationTimeout
});
}
function getTabsDataToSend(tabs) {
return {
type: Constants.kSYNC_DATA_TYPE_TABS,
tabs: tabs.map(tab => ({ url: tab.url, cookieStoreId: tab.cookieStoreId })),
structure : TreeBehavior.getTreeStructureFromTabs(tabs).map(item => item.parent)
};
}
export function manageDevices(windowId) {
if (mExternalProvider)
return mExternalProvider.manageDevices(windowId);
browser.tabs.create({
windowId,
url: `${Constants.kSHORTHAND_URIS.options}#syncTabsToDeviceOptions`
});
}