559 lines
22 KiB
TypeScript
559 lines
22 KiB
TypeScript
import * as loader from "tc-loader";
|
|
import {initializeI18N, tra} from "./i18n/localize";
|
|
import * as fidentity from "./profiles/identities/TeaForumIdentity";
|
|
import * as global_ev_handler from "./events/ClientGlobalControlHandler";
|
|
import {AppParameters, settings, Settings, UrlParameterBuilder, UrlParameterParser} from "tc-shared/settings";
|
|
import {LogCategory, logDebug, logError, logInfo, logWarn} from "tc-shared/log";
|
|
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
|
import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal";
|
|
import {RecorderProfile, setDefaultRecorder} from "tc-shared/voice/RecorderProfile";
|
|
import {formatMessage} from "tc-shared/ui/frames/chat";
|
|
import {openModalNewcomer} from "tc-shared/ui/modal/ModalNewcomer";
|
|
import {global_client_actions} from "tc-shared/events/GlobalEvents";
|
|
import {MenuEntryType, spawn_context_menu} from "tc-shared/ui/elements/ContextMenu";
|
|
import {copyToClipboard} from "tc-shared/utils/helpers";
|
|
import {checkForUpdatedApp} from "tc-shared/update";
|
|
import {setupJSRender} from "tc-shared/ui/jsrender";
|
|
import {ConnectRequestData} from "tc-shared/ipc/ConnectHandler";
|
|
import {defaultConnectProfile, findConnectProfile} from "tc-shared/profiles/ConnectionProfile";
|
|
import {server_connections} from "tc-shared/ConnectionManager";
|
|
import {spawnConnectModalNew} from "tc-shared/ui/modal/connect/Controller";
|
|
import {initializeKeyControl} from "./KeyControl";
|
|
import {assertMainApplication} from "tc-shared/ui/utils";
|
|
import {clientServiceInvite} from "tc-shared/clientservice";
|
|
import {ActionResult} from "tc-services";
|
|
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
|
|
import {ErrorCode} from "tc-shared/connection/ErrorCode";
|
|
import {bookmarks} from "tc-shared/Bookmarks";
|
|
import {getAudioBackend, OutputDevice} from "tc-shared/audio/Player";
|
|
|
|
/* required import for init */
|
|
import "svg-sprites/client-icons";
|
|
import "../css/load-css"
|
|
import "./proto";
|
|
import "./profiles/ConnectionProfile";
|
|
import "./update/UpdaterWeb";
|
|
import "./file/LocalIcons";
|
|
import "./connection/CommandHandler";
|
|
import "./connection/ConnectionBase";
|
|
import "./connection/rtc/Connection";
|
|
import "./connection/rtc/video/Connection";
|
|
import "./video/VideoSource";
|
|
import "./media/Video";
|
|
import "./ui/AppController";
|
|
import "./ui/frames/menu-bar/MainMenu";
|
|
import "./ui/modal/connect/Controller";
|
|
import "./ui/modal/video-viewer/Controller";
|
|
import "./ui/modal/avatar-upload/Controller";
|
|
import "./ui/elements/ContextDivider";
|
|
import "./ui/elements/Tab";
|
|
import "./clientservice";
|
|
import "./text/bbcode/InviteController";
|
|
import "./text/bbcode/YoutubeController";
|
|
import {initializeSounds, setSoundMasterVolume} from "./audio/Sounds";
|
|
import {getInstanceConnectHandler, setupIpcHandler} from "./ipc/BrowserIPC";
|
|
import {promptYesNo} from "tc-shared/ui/modal/yes-no/Controller";
|
|
|
|
assertMainApplication();
|
|
|
|
let preventWelcomeUI = false;
|
|
async function initialize() {
|
|
try {
|
|
await initializeI18N();
|
|
} catch(error) {
|
|
console.error(tr("Failed to initialized the translation system!\nError: %o"), error);
|
|
loader.critical_error("Failed to setup the translation system");
|
|
return;
|
|
}
|
|
|
|
setupIpcHandler();
|
|
}
|
|
|
|
async function initializeApp() {
|
|
global_ev_handler.initialize(global_client_actions);
|
|
getAudioBackend().setMasterVolume(settings.getValue(Settings.KEY_SOUND_MASTER) / 100);
|
|
getAudioBackend().executeWhenInitialized(() => {
|
|
const defaultDeviceId = getAudioBackend().getDefaultDeviceId();
|
|
let targetDeviceId = settings.getValue(Settings.KEY_SPEAKER_DEVICE_ID, OutputDevice.DefaultDeviceId);
|
|
if(targetDeviceId === OutputDevice.DefaultDeviceId) {
|
|
targetDeviceId = defaultDeviceId;
|
|
}
|
|
|
|
getAudioBackend().setCurrentDevice(targetDeviceId).catch(error => {
|
|
logWarn(LogCategory.AUDIO, tr("Failed to set last used output speaker device: %o"), error);
|
|
if(targetDeviceId !== defaultDeviceId) {
|
|
getAudioBackend().setCurrentDevice(defaultDeviceId).catch(error => {
|
|
logError(LogCategory.AUDIO, tr("Failed to set output device to default device: %o"), error);
|
|
createErrorModal(tr("Failed to initialize output device"), tr("Failed to initialize output device.")).open();
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
const recorder = new RecorderProfile("default");
|
|
try {
|
|
await recorder.initialize();
|
|
} catch (error) {
|
|
/* TODO: Recover into a defined state? */
|
|
logError(LogCategory.AUDIO, tr("Failed to initialize default recorder: %o"), error);
|
|
}
|
|
setDefaultRecorder(recorder);
|
|
|
|
initializeSounds().then(() => {
|
|
logInfo(LogCategory.AUDIO, tr("Sounds initialized"));
|
|
});
|
|
setSoundMasterVolume(settings.getValue(Settings.KEY_SOUND_MASTER_SOUNDS) / 100);
|
|
}
|
|
|
|
/* The native client has received a connect request. */
|
|
export function handleNativeConnectRequest(url: URL) {
|
|
let serverAddress = url.host;
|
|
if(url.searchParams.has("port")) {
|
|
if(serverAddress.indexOf(':') !== -1) {
|
|
logWarn(LogCategory.GENERAL, tr("Received connect request which specified the port twice (via parameter and host). Using host port."));
|
|
} else if(serverAddress.indexOf(":") === -1) {
|
|
serverAddress += ":" + url.searchParams.get("port");
|
|
} else {
|
|
serverAddress = `[${serverAddress}]:${url.searchParams.get("port")}`;
|
|
}
|
|
}
|
|
|
|
handleConnectRequest(serverAddress, undefined, new UrlParameterParser(url)).then(undefined);
|
|
}
|
|
|
|
export async function handleConnectRequest(serverAddress: string, serverUniqueId: string | undefined, parameters: UrlParameterParser) {
|
|
const inviteLinkId = parameters.getValue(AppParameters.KEY_CONNECT_INVITE_REFERENCE, undefined);
|
|
logDebug(LogCategory.STATISTICS, tr("Executing connect request with invite key reference: %o"), inviteLinkId);
|
|
|
|
if(inviteLinkId) {
|
|
clientServiceInvite.logAction(inviteLinkId, "ConnectAttempt").then(result => {
|
|
if(result.status !== "success") {
|
|
logWarn(LogCategory.STATISTICS, tr("Failed to register connect attempt: %o"), result.result);
|
|
}
|
|
});
|
|
}
|
|
|
|
const result = await doHandleConnectRequest(serverAddress, serverUniqueId, parameters);
|
|
if(inviteLinkId) {
|
|
let promise: Promise<ActionResult<void>>;
|
|
switch (result.status) {
|
|
case "success":
|
|
promise = clientServiceInvite.logAction(inviteLinkId, "ConnectSuccess");
|
|
break;
|
|
|
|
case "channel-already-joined":
|
|
case "server-already-joined":
|
|
promise = clientServiceInvite.logAction(inviteLinkId, "ConnectNoAction", { reason: result.status });
|
|
break;
|
|
|
|
default:
|
|
promise = clientServiceInvite.logAction(inviteLinkId, "ConnectFailure", { reason: result.status });
|
|
break;
|
|
}
|
|
|
|
promise.then(result => {
|
|
if(result.status !== "success") {
|
|
logWarn(LogCategory.STATISTICS, tr("Failed to register connect result: %o"), result.result);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
type ConnectRequestResult = {
|
|
status:
|
|
"success" |
|
|
"profile-invalid" |
|
|
"client-aborted" |
|
|
"server-join-failed" |
|
|
"server-already-joined" |
|
|
"channel-already-joined" |
|
|
"channel-not-visible" |
|
|
"channel-join-failed"
|
|
}
|
|
|
|
/**
|
|
* @param serverAddress The target address to connect to
|
|
* @param serverUniqueId If given a server unique id. If any of our current connections matches it, such connection will be used
|
|
* @param parameters General connect parameters from the connect URL
|
|
*/
|
|
async function doHandleConnectRequest(serverAddress: string, serverUniqueId: string | undefined, parameters: UrlParameterParser) : Promise<ConnectRequestResult> {
|
|
let targetServerConnection: ConnectionHandler;
|
|
let isCurrentServerConnection: boolean;
|
|
|
|
if(serverUniqueId) {
|
|
if(server_connections.getActiveConnectionHandler()?.getCurrentServerUniqueId() === serverUniqueId) {
|
|
targetServerConnection = server_connections.getActiveConnectionHandler();
|
|
isCurrentServerConnection = true;
|
|
} else {
|
|
targetServerConnection = server_connections.getAllConnectionHandlers().find(connection => connection.getCurrentServerUniqueId() === serverUniqueId);
|
|
isCurrentServerConnection = false;
|
|
}
|
|
}
|
|
|
|
const profileId = parameters.getValue(AppParameters.KEY_CONNECT_PROFILE, undefined);
|
|
const profile = findConnectProfile(profileId) || targetServerConnection?.serverConnection.handshake_handler()?.parameters.profile || defaultConnectProfile();
|
|
|
|
if(!profile || !profile.valid()) {
|
|
spawnConnectModalNew({
|
|
selectedAddress: serverAddress,
|
|
selectedProfile: profile
|
|
});
|
|
return { status: "profile-invalid" };
|
|
}
|
|
|
|
if(!getAudioBackend().isInitialized()) {
|
|
/* Trick the client into clicking somewhere on the site to initialize audio */
|
|
const result = await promptYesNo({
|
|
title: tra("Connect to {}", serverAddress),
|
|
question: tra("Would you like to connect to {}?", serverAddress)
|
|
});
|
|
|
|
if(!result) {
|
|
/* Well... the client don't want to... */
|
|
return { status: "client-aborted" };
|
|
}
|
|
|
|
await new Promise<void>(resolve => getAudioBackend().executeWhenInitialized(resolve));
|
|
}
|
|
|
|
const clientNickname = parameters.getValue(AppParameters.KEY_CONNECT_NICKNAME, undefined);
|
|
|
|
const serverPassword = parameters.getValue(AppParameters.KEY_CONNECT_SERVER_PASSWORD, undefined);
|
|
const passwordsHashed = parameters.getValue(AppParameters.KEY_CONNECT_PASSWORDS_HASHED);
|
|
|
|
const channel = parameters.getValue(AppParameters.KEY_CONNECT_CHANNEL, undefined);
|
|
const channelPassword = parameters.getValue(AppParameters.KEY_CONNECT_CHANNEL_PASSWORD, undefined);
|
|
|
|
const connectToken = parameters.getValue(AppParameters.KEY_CONNECT_TOKEN, undefined);
|
|
|
|
if(!targetServerConnection) {
|
|
targetServerConnection = server_connections.getActiveConnectionHandler();
|
|
if(targetServerConnection.connected) {
|
|
targetServerConnection = server_connections.spawnConnectionHandler();
|
|
}
|
|
}
|
|
|
|
server_connections.setActiveConnectionHandler(targetServerConnection);
|
|
if(targetServerConnection.getCurrentServerUniqueId() === serverUniqueId) {
|
|
/* Just join the new channel and may use the token (before) */
|
|
|
|
if(connectToken) {
|
|
try {
|
|
await targetServerConnection.serverConnection.send_command("tokenuse", { token: connectToken }, { process_result: false });
|
|
} catch (error) {
|
|
if(error instanceof CommandResult) {
|
|
if(error.id === ErrorCode.TOKEN_INVALID_ID) {
|
|
targetServerConnection.log.log("error.custom", { message: tr("Try to use invite key token but the token is invalid.")});
|
|
} else if(error.id == ErrorCode.TOKEN_EXPIRED) {
|
|
targetServerConnection.log.log("error.custom", { message: tr("Try to use invite key token but the token is expired.")});
|
|
} else if(error.id === ErrorCode.TOKEN_USE_LIMIT_EXCEEDED) {
|
|
targetServerConnection.log.log("error.custom", { message: tr("Try to use invite key token but the token has been used too many times.")});
|
|
} else {
|
|
targetServerConnection.log.log("error.custom", { message: tra("Try to use invite key token but an error occurred: {}", error.formattedMessage())});
|
|
}
|
|
} else {
|
|
logError(LogCategory.GENERAL, tr("Failed to use token: {}"), error);
|
|
}
|
|
}
|
|
}
|
|
|
|
if(!channel) {
|
|
/* No need to join any channel */
|
|
if(!connectToken) {
|
|
createInfoModal(tr("Already connected"), tr("You're already connected to the target server.")).open();
|
|
} else {
|
|
/* Don't show a message since a token has been used */
|
|
}
|
|
|
|
return { status: "server-already-joined" };
|
|
}
|
|
|
|
const targetChannel = targetServerConnection.channelTree.resolveChannelPath(channel);
|
|
if(!targetChannel) {
|
|
createErrorModal(tr("Missing target channel"), tr("Failed to join channel since it is not visible.")).open();
|
|
return { status: "channel-not-visible" };
|
|
}
|
|
|
|
if(targetServerConnection.getClient().currentChannel() === targetChannel) {
|
|
createErrorModal(tr("Channel already joined"), tr("You already joined the channel.")).open();
|
|
return { status: "channel-already-joined" };
|
|
}
|
|
|
|
if(targetChannel.getCachedPasswordHash()) {
|
|
const succeeded = await targetChannel.joinChannel();
|
|
if(succeeded) {
|
|
/* Successfully joined channel with a password we already knew */
|
|
return { status: "success" };
|
|
}
|
|
}
|
|
|
|
targetChannel.setCachedHashedPassword(channelPassword);
|
|
/* Force join the channel. Either we have the password, can ignore the password or we don't want to join. */
|
|
if(await targetChannel.joinChannel(true)) {
|
|
return { status: "success" };
|
|
} else {
|
|
/* TODO: More detail? */
|
|
return { status: "channel-join-failed" };
|
|
}
|
|
} else {
|
|
await targetServerConnection.startConnectionNew({
|
|
targetAddress: serverAddress,
|
|
|
|
nickname: clientNickname,
|
|
nicknameSpecified: false,
|
|
|
|
profile: profile,
|
|
token: connectToken,
|
|
|
|
serverPassword: serverPassword,
|
|
serverPasswordHashed: passwordsHashed,
|
|
|
|
defaultChannel: channel,
|
|
defaultChannelPassword: channelPassword,
|
|
defaultChannelPasswordHashed: passwordsHashed
|
|
}, false);
|
|
|
|
if(targetServerConnection.connected) {
|
|
return { status: "success" };
|
|
} else {
|
|
/* TODO: More detail? */
|
|
return { status: "server-join-failed" };
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Used by the old native clients (an within the multi instance handler). Delete it later */
|
|
export function handle_connect_request(properties: ConnectRequestData, _connection: ConnectionHandler) {
|
|
const urlBuilder = new UrlParameterBuilder();
|
|
urlBuilder.setValue(AppParameters.KEY_CONNECT_PROFILE, properties.profile);
|
|
urlBuilder.setValue(AppParameters.KEY_CONNECT_NICKNAME, properties.username);
|
|
|
|
urlBuilder.setValue(AppParameters.KEY_CONNECT_SERVER_PASSWORD, properties.password?.value);
|
|
urlBuilder.setValue(AppParameters.KEY_CONNECT_PASSWORDS_HASHED, properties.password?.hashed);
|
|
|
|
const url = new URL(`https://localhost/?${urlBuilder.build()}`);
|
|
handleConnectRequest(properties.address, undefined, new UrlParameterParser(url));
|
|
}
|
|
|
|
function main() {
|
|
/* initialize font */
|
|
{
|
|
const font = settings.getValue(Settings.KEY_FONT_SIZE);
|
|
|
|
document.body.style.fontSize = font + "px";
|
|
settings.globalChangeListener(Settings.KEY_FONT_SIZE, value => {
|
|
document.body.style.fontSize = value + "px";
|
|
});
|
|
}
|
|
|
|
/* context menu prevent */
|
|
document.addEventListener("contextmenu", event => {
|
|
if(event.defaultPrevented) {
|
|
return;
|
|
}
|
|
|
|
if(event.target instanceof HTMLInputElement) {
|
|
const target = event.target;
|
|
if((!!event.target.value || __build.target === "client") && !event.target.disabled && !event.target.readOnly && event.target.type !== "number") {
|
|
spawn_context_menu(event.pageX, event.pageY, {
|
|
type: MenuEntryType.ENTRY,
|
|
name: tr("Copy"),
|
|
callback: () => copyToClipboard(target.value),
|
|
icon_class: "client-copy",
|
|
visible: !!event.target.value
|
|
}, {
|
|
type: MenuEntryType.ENTRY,
|
|
name: tr("Paste"),
|
|
callback: () => {
|
|
const { clipboard } = __non_webpack_require__('electron');
|
|
target.value = clipboard.readText();
|
|
},
|
|
icon_class: "client-copy",
|
|
visible: __build.target === "client",
|
|
});
|
|
}
|
|
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
if(settings.getValue(Settings.KEY_DISABLE_GLOBAL_CONTEXT_MENU)) {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
window.removeLoaderContextMenuHook();
|
|
|
|
const initialHandler = server_connections.spawnConnectionHandler();
|
|
server_connections.setActiveConnectionHandler(initialHandler);
|
|
initialHandler.acquireInputHardware().then(() => {});
|
|
|
|
/** Setup the XF forum identity **/
|
|
fidentity.update_forum();
|
|
initializeKeyControl();
|
|
|
|
checkForUpdatedApp();
|
|
|
|
if(settings.getValue(Settings.KEY_USER_IS_NEW) && !preventWelcomeUI) {
|
|
const modal = openModalNewcomer();
|
|
modal.close_listener.push(() => settings.setValue(Settings.KEY_USER_IS_NEW, false));
|
|
}
|
|
}
|
|
|
|
const task_teaweb_starter: loader.Task = {
|
|
name: "voice app starter",
|
|
function: async () => {
|
|
try {
|
|
await initializeApp();
|
|
main();
|
|
loader.config.abortAnimationOnFinish = settings.getValue(Settings.KEY_LOADER_ANIMATION_ABORT);
|
|
} catch (ex) {
|
|
console.error(ex.stack);
|
|
if(ex instanceof ReferenceError || ex instanceof TypeError) {
|
|
ex = ex.name + ": " + ex.message;
|
|
}
|
|
loader.critical_error("Failed to invoke main function:<br>" + ex);
|
|
}
|
|
},
|
|
priority: 10
|
|
};
|
|
|
|
const task_connect_handler: loader.Task = {
|
|
name: "Connect handler",
|
|
function: async () => {
|
|
const address = AppParameters.getValue(AppParameters.KEY_CONNECT_ADDRESS, undefined);
|
|
if(typeof address === "undefined") {
|
|
loader.register_task(loader.Stage.LOADED, task_teaweb_starter);
|
|
return;
|
|
}
|
|
|
|
/* FIXME: All additional connect parameters! */
|
|
const connectData = {
|
|
address: address,
|
|
|
|
profile: AppParameters.getValue(AppParameters.KEY_CONNECT_PROFILE, ""),
|
|
username: AppParameters.getValue(AppParameters.KEY_CONNECT_NICKNAME, ""),
|
|
|
|
password: {
|
|
value: AppParameters.getValue(AppParameters.KEY_CONNECT_SERVER_PASSWORD, ""),
|
|
hashed: true
|
|
}
|
|
};
|
|
|
|
const chandler = getInstanceConnectHandler();
|
|
if(chandler && AppParameters.getValue(AppParameters.KEY_CONNECT_NO_SINGLE_INSTANCE)) {
|
|
try {
|
|
await chandler.post_connect_request(connectData, async () => {
|
|
return await promptYesNo({
|
|
title: tr("Another TeaWeb instance is already running"),
|
|
question: tra("Another TeaWeb instance is already running.\nWould you like to connect there?")
|
|
});
|
|
});
|
|
logInfo(LogCategory.CLIENT, tr("Executed connect successfully in another browser window. Closing this window"));
|
|
|
|
createInfoModal(
|
|
tr("Connecting successfully within other instance"),
|
|
formatMessage(tr("You're connecting to {0} within the other TeaWeb instance.\nYou could now close this page."), connectData.address),
|
|
{
|
|
closeable: false,
|
|
footer: undefined
|
|
}
|
|
).open();
|
|
return;
|
|
} catch(error) {
|
|
logInfo(LogCategory.CLIENT, tr("Failed to execute connect within other TeaWeb instance. Using this one. Error: %o"), error);
|
|
}
|
|
|
|
if(chandler) {
|
|
/* no instance avail, so lets make us avail */
|
|
chandler.callback_available = () => {
|
|
return !settings.getValue(Settings.KEY_DISABLE_MULTI_SESSION);
|
|
};
|
|
|
|
chandler.callback_execute = data => {
|
|
preventWelcomeUI = true;
|
|
handle_connect_request(data, server_connections.spawnConnectionHandler());
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
preventWelcomeUI = true;
|
|
preventExecuteAutoConnect = true;
|
|
|
|
loader.register_task(loader.Stage.LOADED, {
|
|
priority: 0,
|
|
function: async () => {
|
|
handleConnectRequest(address, undefined, AppParameters.Instance).then(undefined);
|
|
},
|
|
name: tr("default url connect")
|
|
});
|
|
|
|
loader.register_task(loader.Stage.LOADED, task_teaweb_starter);
|
|
},
|
|
priority: 10
|
|
};
|
|
|
|
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
|
|
name: "jrendere initialize",
|
|
function: async () => {
|
|
try {
|
|
if(!setupJSRender())
|
|
throw "invalid load";
|
|
} catch (error) {
|
|
loader.critical_error(tr("Failed to setup jsrender"));
|
|
console.error(tr("Failed to load jsrender! %o"), error);
|
|
return;
|
|
}
|
|
},
|
|
priority: 110
|
|
});
|
|
|
|
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
|
|
name: "app starter",
|
|
function: async () => {
|
|
try {
|
|
await initialize();
|
|
|
|
if(__build.target == "web") {
|
|
loader.register_task(loader.Stage.LOADED, task_connect_handler);
|
|
} else {
|
|
loader.register_task(loader.Stage.LOADED, task_teaweb_starter);
|
|
}
|
|
} catch (ex) {
|
|
if(ex instanceof Error || typeof(ex.stack) !== "undefined") {
|
|
console.error((tr || (msg => msg))("Critical error stack trace: %o"), ex.stack);
|
|
}
|
|
|
|
if(ex instanceof ReferenceError || ex instanceof TypeError) {
|
|
ex = ex.name + ": " + ex.message;
|
|
}
|
|
|
|
loader.critical_error("Failed to boot app function:<br>" + ex);
|
|
}
|
|
},
|
|
priority: 1000
|
|
});
|
|
|
|
loader.register_task(loader.Stage.LOADED, {
|
|
name: "error task",
|
|
function: async () => {
|
|
if(AppParameters.getValue(AppParameters.KEY_LOAD_DUMMY_ERROR)) {
|
|
loader.critical_error("The tea is cold!", "Argh, this is evil! Cold tea does not taste good.");
|
|
throw "The tea is cold!";
|
|
}
|
|
},
|
|
priority: 2000
|
|
});
|
|
|
|
let preventExecuteAutoConnect = false;
|
|
loader.register_task(loader.Stage.LOADED, {
|
|
priority: 0,
|
|
function: async () => {
|
|
if(preventExecuteAutoConnect) {
|
|
return;
|
|
}
|
|
|
|
bookmarks.executeAutoConnect();
|
|
},
|
|
name: tr("bookmark auto connect")
|
|
}); |