feat: show call participants in room list (Discord-style)
Docker / Docker Buildx (push) Has been cancelled
Build Debian package / Build package (release) Has been cancelled
Build and Deploy / prepare (release) Has been cancelled
Deploy release / Deploy to Cloudflare Pages (release) Has been cancelled
Build and Deploy / Trigger Pro pipeline (release) Has been cancelled
Build and Deploy / Windows arm64 (release) Has been cancelled
Build and Deploy / Windows x64 (release) Has been cancelled
Build and Deploy / macOS (release) Has been cancelled
Build and Deploy / Linux amd64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / Linux arm64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / ${{ needs.prepare.outputs.deploy == 'true' && 'Deploy' || 'Deploy (dry-run)' }} (release) Has been cancelled
Build and Deploy / Deploy builds to ESS (release) Has been cancelled

This commit is contained in:
sorB
2026-05-10 14:25:35 +02:00
parent b797925316
commit 3da363517f
4610 changed files with 827237 additions and 1 deletions
+22
View File
@@ -0,0 +1,22 @@
/*
Copyright 2021-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type BrowserWindow } from "electron";
import { type AppLocalization } from "../language-helper.js";
// global type extensions need to use var for whatever reason
/* eslint-disable no-var */
declare global {
type IConfigOptions = Record<string, any>;
var mainWindow: BrowserWindow | null;
var appQuitting: boolean;
var appLocalization: AppLocalization;
var vectorConfig: IConfigOptions;
}
/* eslint-enable no-var */
+136
View File
@@ -0,0 +1,136 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
declare module "matrix-seshat" {
interface IConfig {
language?: string;
passphrase?: string;
}
/* eslint-disable camelcase */
interface IMatrixEvent {
event_id: string;
sender: string;
room_id: string;
origin_server_ts: number;
content: Record<string, any>;
}
interface IMatrixProfile {
displayname?: string;
avatar_url?: string;
}
interface ISearchArgs {
searchTerm: number;
limit: number;
before_limit: number;
after_limit: number;
order_by_recency: boolean;
next_batch?: string;
}
interface ISearchContext {
events_before: IMatrixEvent[];
events_after: IMatrixEvent[];
profile_info: { [userId: string]: IMatrixProfile };
}
interface ISearchResult {
next_batch: string;
count: number;
results: Array<{
rank: number;
result: IMatrixEvent;
context: ISearchContext;
}>;
}
/* eslint-enable camelcase */
interface ICheckpoint {
roomId: string;
token: string;
fullCrawl: boolean;
direction: "b" | "f";
}
interface IDatabaseStats {
size: number;
eventCount: number;
roomCount: number;
}
interface ILoadArgs {
roomId: string;
limit: number;
fromEvent: string;
direction: "b" | "f";
}
interface ILoadResult {
event: IMatrixEvent;
matrixProfile: IMatrixProfile;
}
export class Seshat {
public constructor(path: string, config?: IConfig);
public addEvent(matrixEvent: IMatrixEvent, profile?: IMatrixProfile): void;
public deleteEvent(eventId: string): Promise<boolean>;
public commit(force?: boolean): Promise<number>;
public commitSync(wait?: boolean, force?: boolean): number;
public reload(): void;
public search(args: ISearchArgs): Promise<ISearchResult>;
public searchSync(
term: string,
limit?: number,
beforeLimit?: number,
afterLimit?: number,
orderByRecency?: boolean,
): ISearchResult;
public addHistoricEventsSync(
events: IMatrixEvent[],
newCheckpoint?: ICheckpoint,
oldCheckpoint?: ICheckpoint,
): boolean;
public addHistoricEvents(
events: IMatrixEvent[],
newCheckpoint?: ICheckpoint,
oldCheckpoint?: ICheckpoint,
): Promise<boolean>;
public addCrawlerCheckpoint(checkpoint: ICheckpoint): Promise<void>;
public removeCrawlerCheckpoint(checkpoint: ICheckpoint): Promise<void>;
public loadCheckpoints(): Promise<ICheckpoint[]>;
public getSize(): Promise<number>;
public getStats(): Promise<IDatabaseStats>;
public delete(): Promise<void>;
public shutdown(): Promise<void>;
public changePassphrase(newPassphrase: string): Promise<void>;
public isEmpty(): Promise<boolean>;
public isRoomIndexed(roomId: string): Promise<boolean>;
public getUserVersion(): Promise<number>;
public setUserVersion(version: number): Promise<void>;
public loadFileEvents(args: ILoadArgs): Promise<ILoadResult[]>;
}
interface IRecoveryInfo {
totalEvents: number;
reindexedEvents: number;
done: number;
}
export class SeshatRecovery {
public constructor(path: string, config?: IConfig);
public info(): IRecoveryInfo;
public getUserVersion(): Promise<number>;
public shutdown(): Promise<void>;
public reindex(): Promise<void>;
}
export class ReindexError extends Error {
public constructor(message?: string);
}
}
+33
View File
@@ -0,0 +1,33 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import { tryPaths } from "./utils.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
let asarPathPromise: Promise<string> | undefined;
// Get the webapp resource file path, memoizes result
export function getAsarPath(): Promise<string> {
if (!asarPathPromise) {
asarPathPromise = tryPaths("webapp", __dirname, [
// If run from the source checkout, this will be in the directory above
"../webapp.asar",
// but if run from a packaged application, electron-main.js will be in
// a different asar file, so it will be two levels above
"../../webapp.asar",
// also try without the 'asar' suffix to allow symlinking in a directory
"../webapp",
// from a packaged application
"../../webapp",
]);
}
return asarPathPromise;
}
+50
View File
@@ -0,0 +1,50 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import BaseAutoLaunch from "auto-launch";
import Store from "./store.js";
export type AutoLaunchState = "enabled" | "minimised" | "disabled";
// Wrapper around auto-launch to get/set the `isHidden` option
export class AutoLaunch extends BaseAutoLaunch {
private static internalInstance?: AutoLaunch;
public static get instance(): AutoLaunch {
if (!AutoLaunch.internalInstance) {
if (!Store.instance) throw new Error("Store not initialized");
AutoLaunch.internalInstance = new AutoLaunch({
name: global.vectorConfig.brand || "Element",
isHidden: Store.instance.get("openAtLoginMinimised"),
mac: {
useLaunchAgent: true,
},
});
}
return AutoLaunch.internalInstance;
}
public async getState(): Promise<AutoLaunchState> {
if (!(await this.isEnabled())) {
return "disabled";
}
return this.opts.isHiddenOnLaunch ? "minimised" : "enabled";
}
public async setState(state: AutoLaunchState): Promise<void> {
const openAtLoginMinimised = state === "minimised";
Store.instance?.set("openAtLoginMinimised", openAtLoginMinimised);
this.opts.isHiddenOnLaunch = openAtLoginMinimised;
if (state !== "disabled") {
return this.enable();
} else {
return this.disable();
}
}
}
+49
View File
@@ -0,0 +1,49 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { app, ipcMain, type IpcMainEvent, nativeImage } from "electron";
import { _t } from "./language-helper.js";
// Handles calculating the correct "badge" for the window, for notifications and error states.
// Tray icon updates are handled in tray.ts
if (process.platform === "win32") {
// We only use setOverlayIcon on Windows as it's only supported on that platform, but has good support
// from all the Windows variants we support.
// https://www.electronjs.org/docs/latest/api/browser-window#winsetoverlayiconoverlay-description-windows
ipcMain.on(
"setBadgeCount",
function (_ev: IpcMainEvent, count: number, imageBuffer?: Buffer, isError?: boolean): void {
if (count === 0) {
// Flash frame is set to true in ipc.ts "loudNotification"
global.mainWindow?.flashFrame(false);
}
if (imageBuffer) {
global.mainWindow?.setOverlayIcon(
nativeImage.createFromBuffer(Buffer.from(imageBuffer)),
isError
? _t("icon_overlay|description_error")
: _t("icon_overlay|description_notifications", { count }),
);
} else {
global.mainWindow?.setOverlayIcon(null, "");
}
},
);
} else {
// only set badgeCount on Mac/Linux, the docs say that only those platforms support it but turns out Electron
// has some Windows support too, and in some Windows environments this leads to two badges rendering atop
// each other. See https://github.com/vector-im/element-web/issues/16942
ipcMain.on("setBadgeCount", function (_ev: IpcMainEvent, count: number): void {
if (count === 0) {
// Flash frame is set to true in ipc.ts "loudNotification"
global.mainWindow?.flashFrame(false);
}
app.badgeCount = count;
});
}
+38
View File
@@ -0,0 +1,38 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import path, { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { type JsonObject, loadJsonFile } from "./utils.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
let buildConfig: BuildConfig;
interface BuildConfig {
// Application User Model ID
appId: string;
// Protocol string used for OIDC callbacks
protocol: string;
// Subject name of the code signing cert used for Windows packages, if signed
// used as a basis for the Tray GUID which must be rolled if the certificate changes.
windowsCertSubjectName: string | undefined;
}
export function getBuildConfig(): BuildConfig {
if (!buildConfig) {
const packageJson = loadJsonFile(path.join(__dirname, "..", "package.json")) as JsonObject;
buildConfig = {
appId: (packageJson["electron_appId"] as string) || "im.riot.app",
protocol: (packageJson["electron_protocol"] as string) || "io.element.desktop",
windowsCertSubjectName: packageJson["electron_windows_cert_sn"] as string,
};
}
return buildConfig;
}
+10
View File
@@ -0,0 +1,10 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export function getBrand(): string {
return global.vectorConfig.brand || "Element";
}
+20
View File
@@ -0,0 +1,20 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { Streams } from "electron";
type DisplayMediaCallback = (streams: Streams) => void;
let displayMediaCallback: DisplayMediaCallback | null;
export const getDisplayMediaCallback = (): DisplayMediaCallback | null => {
return displayMediaCallback;
};
export const setDisplayMediaCallback = (callback: DisplayMediaCallback | null): void => {
displayMediaCallback = callback;
};
+586
View File
@@ -0,0 +1,586 @@
/*
Copyright 2018-2025 New Vector Ltd.
Copyright 2017-2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2016 Aviral Dasgupta
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// Squirrel on windows starts the app with various flags as hooks to tell us when we've been installed/uninstalled etc.
import "./squirrelhooks.js";
import {
app,
BrowserWindow,
Menu,
autoUpdater,
dialog,
type Input,
type Event,
session,
protocol,
desktopCapturer,
} from "electron";
// eslint-disable-next-line n/file-extension-in-import
import * as Sentry from "@sentry/electron/main";
import path, { dirname } from "node:path";
import windowStateKeeper from "electron-window-state";
import fs from "node:fs";
import { URL, fileURLToPath } from "node:url";
import minimist from "minimist";
import "./ipc.js";
import "./seshat.js";
import "./settings.js";
import "./badge.js";
import * as tray from "./tray.js";
import Store from "./store.js";
import { buildMenuTemplate } from "./vectormenu.js";
import webContentsHandler from "./webcontents-handler.js";
import * as updater from "./updater.js";
import ProtocolHandler from "./protocol.js";
import { _t, AppLocalization } from "./language-helper.js";
import { setDisplayMediaCallback } from "./displayMediaCallback.js";
import { setupMacosTitleBar } from "./macos-titlebar.js";
import { type Json, loadJsonFile } from "./utils.js";
import { setupMediaAuth } from "./media-auth.js";
import { getBuildConfig } from "./build-config.js";
import { getAsarPath } from "./asar.js";
import { getIconPath } from "./icon.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const argv = minimist(process.argv, {
alias: { help: "h" },
});
if (argv["help"]) {
console.log("Options:");
console.log(" --profile-dir {path}: Path to where to store the profile.");
console.log(
` --profile {name}: Name of alternate profile to use, allows for running multiple accounts.\n` +
` Ignored if --profile-dir is specified.\n` +
` The ELEMENT_PROFILE_DIR environment variable may be used to change the default profile path.\n` +
` It is overridden by --profile-dir, but can be combined with --profile.`,
);
console.log(" --devtools: Install and use react-devtools and react-perf.");
console.log(
` --config: Path to the config.json file. May also be specified via the ELEMENT_DESKTOP_CONFIG_JSON environment variable.\n` +
` Otherwise use the default user location '${app.getPath("userData")}'`,
);
console.log(" --no-update: Disable automatic updating.");
console.log(" --hidden: Start the application hidden in the system tray.");
console.log(" --help: Displays this help message.");
console.log("And more such as --proxy, see: https://electronjs.org/docs/api/command-line-switches");
app.exit();
}
const LocalConfigLocation = process.env.ELEMENT_DESKTOP_CONFIG_JSON ?? argv["config"];
const LocalConfigFilename = "config.json";
// Electron creates the user data directory (with just an empty 'Dictionaries' directory...)
// as soon as the app path is set, so pick a random path in it that must exist if it's a
// real user data directory.
function isRealUserDataDir(d: string): boolean {
return fs.existsSync(path.join(d, "IndexedDB"));
}
const buildConfig = getBuildConfig();
const protocolHandler = new ProtocolHandler(buildConfig.protocol);
// check if we are passed a profile in the SSO callback url
let userDataPath: string;
const userDataPathInProtocol = protocolHandler.getProfileFromDeeplink(argv["_"]);
if (userDataPathInProtocol) {
userDataPath = userDataPathInProtocol;
} else if (argv["profile-dir"]) {
userDataPath = argv["profile-dir"];
} else {
let newUserDataPath = process.env.ELEMENT_PROFILE_DIR ?? app.getPath("userData");
if (argv["profile"]) {
newUserDataPath += "-" + argv["profile"];
}
const newUserDataPathExists = isRealUserDataDir(newUserDataPath);
let oldUserDataPath = path.join(app.getPath("appData"), app.getName().replace("Element", "Riot"));
if (argv["profile"]) {
oldUserDataPath += "-" + argv["profile"];
}
const oldUserDataPathExists = isRealUserDataDir(oldUserDataPath);
console.log(newUserDataPath + " exists: " + (newUserDataPathExists ? "yes" : "no"));
console.log(oldUserDataPath + " exists: " + (oldUserDataPathExists ? "yes" : "no"));
if (!newUserDataPathExists && oldUserDataPathExists) {
console.log("Using legacy user data path: " + oldUserDataPath);
userDataPath = oldUserDataPath;
} else {
userDataPath = newUserDataPath;
}
}
app.setPath("userData", userDataPath);
const homeserverProps = ["default_is_url", "default_hs_url", "default_server_name", "default_server_config"] as const;
function loadLocalConfigFile(): Json {
if (LocalConfigLocation) {
console.log("Loading local config: " + LocalConfigLocation);
return loadJsonFile(LocalConfigLocation);
} else {
const configDir = app.getPath("userData");
console.log(`Loading local config: ${path.join(configDir, LocalConfigFilename)}`);
return loadJsonFile(configDir, LocalConfigFilename);
}
}
let loadConfigPromise: Promise<void> | undefined;
// Loads the config from asar, and applies a config.json from userData atop if one exists
// Writes config to `global.vectorConfig`. Idempotent, returns the same promise on subsequent calls.
function loadConfig(): Promise<void> {
if (loadConfigPromise) return loadConfigPromise;
async function actuallyLoadConfig(): Promise<void> {
const asarPath = await getAsarPath();
try {
console.log(`Loading app config: ${path.join(asarPath, LocalConfigFilename)}`);
global.vectorConfig = loadJsonFile(asarPath, LocalConfigFilename);
} catch {
// it would be nice to check the error code here and bail if the config
// is unparsable, but we get MODULE_NOT_FOUND in the case of a missing
// file or invalid json, so node is just very unhelpful.
// Continue with the defaults (ie. an empty config)
global.vectorConfig = {};
}
try {
// Load local config and use it to override values from the one baked with the build
const localConfig = loadLocalConfigFile();
// If the local config has a homeserver defined, don't use the homeserver from the build
// config. This is to avoid a problem where Riot thinks there are multiple homeservers
// defined, and panics as a result.
if (Object.keys(localConfig).find((k) => homeserverProps.includes(<any>k))) {
// Rip out all the homeserver options from the vector config
global.vectorConfig = Object.keys(global.vectorConfig)
.filter((k) => !homeserverProps.includes(<any>k))
.reduce(
(obj, key) => {
obj[key] = global.vectorConfig[key];
return obj;
},
{} as Omit<Partial<(typeof global)["vectorConfig"]>, keyof typeof homeserverProps>,
);
}
global.vectorConfig = Object.assign(global.vectorConfig, localConfig);
} catch (e) {
if (e instanceof SyntaxError) {
await app.whenReady();
void dialog.showMessageBox({
type: "error",
title: `Your ${global.vectorConfig.brand || "Element"} is misconfigured`,
message:
`Your custom ${global.vectorConfig.brand || "Element"} configuration contains invalid JSON. ` +
`Please correct the problem and reopen ${global.vectorConfig.brand || "Element"}.`,
detail: e.message || "",
});
}
// Could not load local config, this is expected in most cases.
}
// Tweak modules paths as they assume the root is at the same level as webapp, but for `vector://vector/webapp` it is not.
if (Array.isArray(global.vectorConfig.modules)) {
global.vectorConfig.modules = global.vectorConfig.modules.map((m) => {
if (m.startsWith("/")) {
return "/webapp" + m;
}
return m;
});
}
}
loadConfigPromise = actuallyLoadConfig();
return loadConfigPromise;
}
// Configure Electron Sentry and crashReporter using sentry.dsn in config.json if one is present.
async function configureSentry(): Promise<void> {
await loadConfig();
const { dsn, environment } = global.vectorConfig.sentry || {};
if (dsn) {
console.log(`Enabling Sentry with dsn=${dsn} environment=${environment}`);
Sentry.init({
dsn,
environment,
// We don't actually use this IPC, but we do not want Sentry injecting preloads
ipcMode: Sentry.IPCMode.Classic,
});
}
}
global.appQuitting = false;
const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [
(input, platform): boolean => platform !== "darwin" && input.alt && input.key.toUpperCase() === "F4",
(input, platform): boolean => platform !== "darwin" && input.control && input.key.toUpperCase() === "Q",
(input, platform): boolean =>
platform === "darwin" && input.meta && !input.control && input.key.toUpperCase() === "Q",
];
void configureSentry();
// handle uncaught errors otherwise it displays
// stack traces in popup dialogs, which is terrible (which
// it will do any time the auto update poke fails, and there's
// no other way to catch this error).
// Assuming we generally run from the console when developing,
// this is far preferable.
process.on("uncaughtException", function (error: Error): void {
console.log("Unhandled exception", error);
});
app.commandLine.appendSwitch("--enable-usermedia-screen-capturing");
if (!app.commandLine.hasSwitch("enable-features")) {
app.commandLine.appendSwitch("enable-features", "WebRTCPipeWireCapturer");
}
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
console.log("Other instance detected: exiting");
app.exit();
}
// do this after we know we are the primary instance of the app
protocolHandler.initialise(userDataPath);
// Register the scheme the app is served from as 'standard'
// which allows things like relative URLs and IndexedDB to
// work.
// Also mark it as secure (ie. accessing resources from this
// protocol and HTTPS won't trigger mixed content warnings).
protocol.registerSchemesAsPrivileged([
{
scheme: "vector",
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
},
},
]);
// Turn the sandbox on for *all* windows we might generate. Doing this means we don't
// have to specify a `sandbox: true` to each BrowserWindow.
//
// This also fixes an issue with window.open where if we only specified the sandbox
// on the main window we'd run into cryptic "ipc_renderer be broke" errors. Turns out
// it's trying to jump the sandbox and make some calls into electron, which it can't
// do when half of it is sandboxed. By turning on the sandbox for everything, the new
// window (no matter how temporary it may be) is also sandboxed, allowing for a clean
// transition into the user's browser.
app.enableSandbox();
// We disable media controls here. We do this because calls use audio and video elements and they sometimes capture the media keys. See https://github.com/vector-im/element-web/issues/15704
app.commandLine.appendSwitch("disable-features", "HardwareMediaKeyHandling,MediaSessionService");
const store = Store.initialize(argv["storage-mode"]); // must be called before any async actions
// Disable hardware acceleration if the setting has been set.
if (store.get("disableHardwareAcceleration")) {
console.log("Disabling hardware acceleration.");
app.disableHardwareAcceleration();
}
app.on("ready", async () => {
console.debug("Reached Electron ready state");
let asarPath: string;
try {
asarPath = await getAsarPath();
await loadConfig();
} catch (e) {
console.log("App setup failed: exiting", e);
process.exit(1);
// process.exit doesn't cause node to stop running code immediately,
// so return (we could let the exception propagate but then we end up
// with node printing all sorts of stuff about unhandled exceptions
// when we want the actual error to be as obvious as possible).
return;
}
if (argv["devtools"]) {
try {
const { installExtension, REACT_DEVELOPER_TOOLS } = await import("electron-devtools-installer");
installExtension(REACT_DEVELOPER_TOOLS)
.then((ext) => console.log(`Added Extension: ${ext.name}`))
.catch((err: unknown) => console.log("An error occurred: ", err));
} catch (e) {
console.log(e);
}
}
protocol.registerFileProtocol("vector", (request, callback) => {
if (request.method !== "GET") {
callback({ error: -322 }); // METHOD_NOT_SUPPORTED from chromium/src/net/base/net_error_list.h
return null;
}
const parsedUrl = new URL(request.url);
if (parsedUrl.protocol !== "vector:") {
callback({ error: -302 }); // UNKNOWN_URL_SCHEME
return;
}
if (parsedUrl.host !== "vector") {
callback({ error: -105 }); // NAME_NOT_RESOLVED
return;
}
const target = parsedUrl.pathname.split("/");
// path starts with a '/'
if (target[0] !== "") {
callback({ error: -6 }); // FILE_NOT_FOUND
return;
}
if (target[target.length - 1] == "") {
target[target.length - 1] = "index.html";
}
let baseDir: string;
if (target[1] === "webapp") {
baseDir = asarPath;
} else {
callback({ error: -6 }); // FILE_NOT_FOUND
return;
}
// Normalise the base dir and the target path separately, then make sure
// the target path isn't trying to back out beyond its root
baseDir = path.normalize(baseDir);
const relTarget = path.normalize(path.join(...target.slice(2)));
if (relTarget.startsWith("..")) {
callback({ error: -6 }); // FILE_NOT_FOUND
return;
}
const absTarget = path.join(baseDir, relTarget);
callback({
path: absTarget,
});
});
// Minimist parses `--no-`-prefixed arguments as booleans with value `false` rather than verbatim.
if (argv["update"] === false) {
console.log("Auto update disabled via command line flag");
} else if (global.vectorConfig["update_base_url"]) {
void updater.start(global.vectorConfig["update_base_url"]);
} else {
console.log("No update_base_url is defined: auto update is disabled");
}
// Set up i18n before loading storage as we need translations for dialogs
global.appLocalization = new AppLocalization({
components: [(): void => tray.initApplicationMenu(), (): void => Menu.setApplicationMenu(buildMenuTemplate())],
store,
});
// Load the previous window state with fallback to defaults
const mainWindowState = windowStateKeeper({
defaultWidth: 1024,
defaultHeight: 768,
});
console.debug("Opening main window");
const preloadScript = path.normalize(`${__dirname}/preload.cjs`);
global.mainWindow = new BrowserWindow({
// https://www.electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do
backgroundColor: "#fff",
titleBarStyle: process.platform === "darwin" ? "hidden" : "default",
trafficLightPosition: { x: 9, y: 8 },
icon: await getIconPath(),
show: false,
autoHideMenuBar: store.get("autoHideMenuBar"),
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
height: mainWindowState.height,
webPreferences: {
preload: preloadScript,
nodeIntegration: false,
//sandbox: true, // We enable sandboxing from app.enableSandbox() above
contextIsolation: true,
webgl: true,
},
});
global.mainWindow.setContentProtection(store.get("enableContentProtection"));
try {
console.debug("Ensuring storage is ready");
if (!(await store.prepareSafeStorage(global.mainWindow.webContents.session))) return;
} catch (e) {
console.error(e);
app.exit(1);
}
void global.mainWindow.loadURL("vector://vector/webapp/");
if (process.platform === "darwin") {
setupMacosTitleBar(global.mainWindow);
}
// Handle spellchecker
// For some reason spellCheckerEnabled isn't persisted, so we have to use the store here
global.mainWindow.webContents.session.setSpellCheckerEnabled(store.get("spellCheckerEnabled", true));
// Create trayIcon icon
if (store.get("minimizeToTray")) await tray.create();
global.mainWindow.once("ready-to-show", () => {
if (!global.mainWindow) return;
mainWindowState.manage(global.mainWindow);
if (!argv["hidden"]) {
global.mainWindow.show();
} else {
// hide here explicitly because window manage above sometimes shows it
global.mainWindow.hide();
}
});
global.mainWindow.webContents.on("before-input-event", (event: Event, input: Input): void => {
const exitShortcutPressed =
input.type === "keyDown" && exitShortcuts.some((shortcutFn) => shortcutFn(input, process.platform));
// We only care about the exit shortcuts here
if (!exitShortcutPressed || !global.mainWindow) return;
// Prevent the default behaviour
event.preventDefault();
// Let's ask the user if they really want to exit the app
const shouldWarnBeforeExit = store.get("warnBeforeExit", true);
if (shouldWarnBeforeExit) {
const shouldCancelCloseRequest =
dialog.showMessageBoxSync(global.mainWindow, {
type: "question",
buttons: [
_t("action|cancel"),
_t("action|close_brand", {
brand: global.vectorConfig.brand || "Element",
}),
],
message: _t("confirm_quit"),
defaultId: 1,
cancelId: 0,
}) === 0;
if (shouldCancelCloseRequest) return;
}
// Exit the app
app.exit();
});
global.mainWindow.on("closed", () => {
global.mainWindow = null;
});
global.mainWindow.on("close", async (e) => {
// If we are not quitting and have a tray icon then minimize to tray
if (!global.appQuitting && (tray.hasTray() || process.platform === "darwin")) {
// On Mac, closing the window just hides it
// (this is generally how single-window Mac apps
// behave, eg. Mail.app)
e.preventDefault();
if (global.mainWindow?.isFullScreen()) {
global.mainWindow.once("leave-full-screen", () => global.mainWindow?.hide());
global.mainWindow.setFullScreen(false);
} else {
global.mainWindow?.hide();
}
return false;
}
});
if (process.platform === "win32") {
// Handle forward/backward mouse buttons in Windows
global.mainWindow.on("app-command", (e, cmd) => {
if (cmd === "browser-backward" && global.mainWindow?.webContents.canGoBack()) {
global.mainWindow.webContents.goBack();
} else if (cmd === "browser-forward" && global.mainWindow?.webContents.canGoForward()) {
global.mainWindow.webContents.goForward();
}
});
}
webContentsHandler(global.mainWindow.webContents);
session.defaultSession.setDisplayMediaRequestHandler(
(_, callback) => {
if (process.env.XDG_SESSION_TYPE === "wayland") {
// On Wayland, calling getSources() opens the xdg-desktop-portal picker.
// The user can only select a single source there, so Electron will return an array with exactly one entry.
desktopCapturer
.getSources({ types: ["screen", "window"] })
.then((sources) => {
callback({ video: sources[0] });
})
.catch((err) => {
// If the user cancels the dialog an error occurs "Failed to get sources"
console.error("Wayland: failed to get user-selected source:", err);
callback({ video: { id: "", name: "" } }); // The promise does not return if no dummy is passed here as source
});
} else {
global.mainWindow?.webContents.send("openDesktopCapturerSourcePicker");
}
setDisplayMediaCallback(callback);
},
{ useSystemPicker: true },
); // Use Mac OS 15+ native picker
setupMediaAuth(global.mainWindow);
});
app.on("window-all-closed", () => {
app.quit();
});
app.on("activate", () => {
global.mainWindow?.show();
});
function beforeQuit(): void {
global.appQuitting = true;
global.mainWindow?.webContents.send("before-quit");
}
app.on("before-quit", beforeQuit);
autoUpdater.on("before-quit-for-update", beforeQuit);
app.on("second-instance", (ev, commandLine, workingDirectory) => {
// If other instance launched with --hidden then skip showing window
if (commandLine.includes("--hidden")) return;
// Someone tried to run a second instance, we should focus our window.
if (global.mainWindow) {
if (!global.mainWindow.isVisible()) global.mainWindow.show();
if (global.mainWindow.isMinimized()) global.mainWindow.restore();
global.mainWindow.focus();
}
});
// This is required to make notification handlers work
// on Windows 8.1/10/11 (and is a noop on other platforms);
// It must also match the ID found in 'electron-builder'
// in order to get the title and icon to show up correctly.
// Ref: https://stackoverflow.com/a/77314604/3525780
app.setAppUserModelId(buildConfig.appId);
+93
View File
@@ -0,0 +1,93 @@
{
"action": {
"cancel": "Storno",
"close": "Zavřít",
"close_brand": "Zavřít %(brand)s",
"copy": "Zkopírovat",
"cut": "Vyjmout",
"delete": "Smazat",
"edit": "Upravit",
"minimise": "Minimalizovat",
"paste": "Vložit",
"paste_match_style": "Vložit a přizpůsobit styl",
"quit": "Ukončit",
"redo": "Znovu",
"select_all": "Vybrat vše",
"show_hide": "Zobrazit/Skrýt",
"undo": "Zpět",
"zoom_in": "Přiblížit",
"zoom_out": "Oddálit"
},
"common": {
"about": "O",
"brand_help": "%(brand)s nápověda",
"help": "Nápověda",
"no": "Ne",
"preferences": "Předvolby",
"yes": "Ano"
},
"confirm_quit": "Opravdu chcete ukončit aplikaci?",
"edit_menu": {
"speech": "Řeč",
"speech_start_speaking": "Spustit nahrávání hlasu",
"speech_stop_speaking": "Zastavit nahrávání hlasu"
},
"eol": {
"no_more_updates": "Používáte nepodporovanou verzi systému macOS. Prosím upgradujte %(brand)s pro získání aktualizací.",
"title": "Systém není podporován",
"warning": "Používáte nepodporovanou verzi systému macOS. Proveďte prosím upgrade %(brand)s, aby byl stále funkční."
},
"file_menu": {
"label": "Soubor"
},
"icon_overlay": {
"description_error": "Chyba",
"description_notifications": {
"one": "Máte %(count)s nepřečtené oznámení.",
"few": "Máte %(count)s nepřečtená oznámení.",
"other": "Máte %(count)s nepřečtených oznámení."
}
},
"menu": {
"hide": "Skrýt",
"hide_others": "Skrýt ostatní",
"services": "Služby",
"unhide": "Zrušit skrytí"
},
"right_click_menu": {
"add_to_dictionary": "Přidat do slovníku",
"copy_email": "Kopírovat e-mailovou adresu",
"copy_image": "Kopírovat obrázek",
"copy_image_url": "Kopírovat adresu obrázku",
"copy_link_url": "Kopírovat adresu odkazu",
"save_image_as": "Uložit obrázek jako...",
"save_image_as_error_description": "Obrázek se nepodařilo uložit",
"save_image_as_error_title": "Chyba při ukládání obrázku"
},
"store": {
"error": {
"backend_changed": "Vymazat data a znovu načíst?",
"backend_changed_detail": "Nelze získat přístup k tajnému klíči ze systémové klíčenky, zdá se, že se změnil.",
"backend_changed_title": "Nepodařilo se načíst databázi",
"backend_no_encryption": "Váš systém má podporovanou klíčenku, ale šifrování není k dispozici.",
"backend_no_encryption_detail": "Electron zjistil, že pro vaši klíčenku %(backend)s není k dispozici šifrování. Ujistěte se, že máte nainstalovanou klíčenku. Pokud ji máte, restartujte počítač a zkuste to znovu. Volitelně můžete povolit %(brand)s použít slabší formu šifrování.",
"backend_no_encryption_title": "Bez podpory šifrování",
"unsupported_keyring": "Váš systém má nepodporovanou klíčenku, což znamená, že databázi nelze otevřít.",
"unsupported_keyring_detail": "Detekce klíčenky Electronu nenalezla podporovaný backend. Můžete se pokusit ručně nakonfigurovat backend spuštěním %(brand)s s argumentem příkazového řádku, jednorázovou operací. Viz %(link)s.",
"unsupported_keyring_title": "Systém není podporován",
"unsupported_keyring_use_basic_text": "Používat slabší šifrování",
"unsupported_keyring_use_plaintext": "Nepoužívat žádné šifrování"
}
},
"view_menu": {
"actual_size": "Aktuální velikost",
"toggle_developer_tools": "Přepnout zobrazení nástrojů pro vývojáře",
"toggle_full_screen": "Přepnout zobrazení celé obrazovky",
"view": "Zobrazit"
},
"window_menu": {
"bring_all_to_front": "Přenést vše do popředí",
"label": "Okno",
"zoom": "Lupa"
}
}
+93
View File
@@ -0,0 +1,93 @@
{
"action": {
"cancel": "Diddymu",
"close": "Cau",
"close_brand": "Cau %(brand)s",
"copy": "Copïo",
"cut": "Torri",
"delete": "Dileu",
"edit": "Golygu",
"minimise": "Lleihau",
"paste": "Gludo",
"paste_match_style": "Arddull Gludo a Chyfateb",
"quit": "Gadael",
"redo": "Ail-wneud",
"select_all": "Dewis y Cyfan",
"show_hide": "Dangos/Cuddio",
"undo": "Dadwneud",
"zoom_in": "Chwyddo i Mewn",
"zoom_out": "Chwyddo Allan"
},
"common": {
"about": "Ynghylch",
"brand_help": "Cymorth %(brand)s",
"help": "Cymorth",
"no": "Na",
"preferences": "Dewisiadau",
"yes": "Iawn"
},
"confirm_quit": "Ydych chi'n siŵr eich bod am roi'r gorau iddi?",
"edit_menu": {
"speech": "Lleferydd",
"speech_start_speaking": "Cychwyn Llefaru",
"speech_stop_speaking": "Peidio Llefaru"
},
"eol": {
"no_more_updates": "Rydych chi'n rhedeg fersiwn o macOS nad yw'n cael ei chefnogi. Uwchraddiwch i dderbyn diweddariadau %(brand)s.",
"title": "System heb ei chefnogi",
"warning": "Rydych chi'n rhedeg fersiwn o macOS nad yw'n cael ei chefnogi. Uwchraddiwch i sicrhau bod %(brand)s yn parhau i weithio."
},
"file_menu": {
"label": "Ffeil"
},
"icon_overlay": {
"description_error": "Gwall",
"description_notifications": {
"Mae gennych chi %(count)s hysbysiadau heb eu darllen.": "zero",
"Mae gennych chi %(count)s hysbysiad heb ei ddarllen.": "one",
"Mae gennych chi %(count)s hysbysiad heb eu darllen.": "other"
}
},
"menu": {
"hide": "Cuddio",
"hide_others": "Cuddio'r Gweddill",
"services": "Gwasanaethau",
"unhide": "Datguddio"
},
"right_click_menu": {
"add_to_dictionary": "Ychwanegu at y geiriadur",
"copy_email": "Copïo cyfeiriad e-bost",
"copy_image": "Copïo delwedd",
"copy_image_url": "Copïo cyfeiriad delwedd",
"copy_link_url": "Copïo cyfeiriad y ddolen",
"save_image_as": "Cadw delwedd fel...",
"save_image_as_error_description": "Methodd cadw'r ddelwedd",
"save_image_as_error_title": "Methodd cadw'r ddelwedd"
},
"store": {
"error": {
"backend_changed": "Clirio data ac ail-lwytho?",
"backend_changed_detail": "Methu cael mynediad at y gyfrinach o allweddi'r system, mae'n ymddangos ei fod wedi newid.",
"backend_changed_title": "Methwyd llwytho'r gronfa ddata",
"backend_no_encryption": "Mae gan eich system cylch allwedd sy'n cael ei gefnogi ond nid yw amgryptio ar gael.",
"backend_no_encryption_detail": "Mae Electron wedi canfod nad yw amgryptio ar gael ar eich cylch allweddi %(backend)s. Gwnewch yn siŵr bod y cylch allweddi wedi'i osod. Os oes y cylch allweddi wedi'i osod, ail gychwynnwch a cheisiwch eto. Yn ddewisol, gallwch ganiatáu i %(brand)s ddefnyddio ffurf wannach o amgryptio.",
"backend_no_encryption_title": "Dim cefnogaeth amgryptio",
"unsupported_keyring": "Mae gan eich system allweddell nad yw'n cael ei chefnogi sy'n golygu nad oes modd agor y gronfa ddata.",
"unsupported_keyring_detail": "Heb ganfod allweddell Electron gefn. Gallwch geisio ffurfweddu'r gefn â llaw trwy gychwyn %(brand)s gyda dadl llinell orchymyn, gweithrediad untro. Gweler %(link)s.",
"unsupported_keyring_title": "System heb ei chefnogi",
"unsupported_keyring_use_basic_text": "Defnyddiwch amgryptio gwannach",
"unsupported_keyring_use_plaintext": "Peidiwch â defnyddio amgryptio"
}
},
"view_menu": {
"actual_size": "Maint Gwirioneddol",
"toggle_developer_tools": "Toglo Offer Datblygwyr",
"toggle_full_screen": "Toglo Sgrin Lawn",
"view": "Golwg"
},
"window_menu": {
"bring_all_to_front": "Popeth i'r Blaen",
"label": "Ffenestr",
"zoom": "Chwyddo"
}
}
+74
View File
@@ -0,0 +1,74 @@
{
"action": {
"cancel": "Afbryd",
"close": "Luk",
"close_brand": "Luk %(brand)s",
"copy": "Kopiér",
"cut": "Klip",
"delete": "Slet",
"edit": "Rediger",
"minimise": "Minimér",
"paste": "Indsæt",
"paste_match_style": "Indsæt og match stil",
"quit": "Luk",
"redo": "Omgør",
"select_all": "Vælg alle",
"show_hide": "Vis/skjul",
"undo": "Fortryd",
"zoom_in": "Zoom ind",
"zoom_out": "Zoom ud"
},
"common": {
"about": "Om",
"brand_help": "%(brand)sHjælp",
"help": "Hjælp",
"no": "Nej",
"preferences": "Indstillinger",
"yes": "Ja"
},
"confirm_quit": "Er du sikker på, du vil afslutte?",
"edit_menu": {
"speech": "Tale",
"speech_start_speaking": "Begynd at tale",
"speech_stop_speaking": "Stop med at tale"
},
"file_menu": {
"label": "Fil"
},
"icon_overlay": {
"description_error": "Fejl"
},
"menu": {
"hide": "Skjul",
"hide_others": "Skjul andre",
"services": "Tjenester",
"unhide": "Vis"
},
"right_click_menu": {
"add_to_dictionary": "Tilføj til ordbog",
"copy_email": "Kopier e-mailadresse",
"copy_image": "Kopier billede",
"copy_image_url": "Kopier billed-adresse",
"copy_link_url": "Kopier linkadresse",
"save_image_as": "Gem billede som...",
"save_image_as_error_description": "Billedet kunne ikke gemmes",
"save_image_as_error_title": "Kunne ikke gemme billedet"
},
"store": {
"error": {
"backend_no_encryption": "Dit system har en understøttet nøglering, men kryptering er ikke tilgængelig.",
"unsupported_keyring": "Dit system har en ikke-understøttet nøglering, hvilket betyder at databasen ikke kan åbnes."
}
},
"view_menu": {
"actual_size": "Faktisk størrelse",
"toggle_developer_tools": "Skift udviklerværktøjer",
"toggle_full_screen": "Skift fuld skærm",
"view": "Vis"
},
"window_menu": {
"bring_all_to_front": "Bring alt i front",
"label": "Vindue",
"zoom": "Zoom"
}
}
+92
View File
@@ -0,0 +1,92 @@
{
"action": {
"cancel": "Abbrechen",
"close": "Schließen",
"close_brand": "%(brand)s schließen",
"copy": "Kopieren",
"cut": "Ausschneiden",
"delete": "Löschen",
"edit": "Bearbeiten",
"minimise": "Minimieren",
"paste": "Einfügen",
"paste_match_style": "Einfügen und Formatierung beibehalten",
"quit": "Beenden",
"redo": "Wiederherstellen",
"select_all": "Alles auswählen",
"show_hide": "Anzeigen/Ausblenden",
"undo": "Rückgängig",
"zoom_in": "Vergrößern",
"zoom_out": "Verkleinern"
},
"common": {
"about": "Über",
"brand_help": "%(brand)s Hilfe",
"help": "Hilfe",
"no": "Nein",
"preferences": "Präferenzen",
"yes": "Ja"
},
"confirm_quit": "Wirklich beenden?",
"edit_menu": {
"speech": "Sprache",
"speech_start_speaking": "Aufnahme starten",
"speech_stop_speaking": "Aufnahme beenden"
},
"eol": {
"no_more_updates": "Du benutzt eine nicht unterstützte Version von macOS. Bitte aktualisiere, um Updates für %(brand)s zu erhalten.",
"title": "System nicht unterstützt",
"warning": "Du benutzt eine nicht unterstützte Version von macOS. Bitte aktualisiere, damit %(brand)s weiter funktioniert."
},
"file_menu": {
"label": "Datei"
},
"icon_overlay": {
"description_error": "Fehler",
"description_notifications": {
"one": "Du hast %(count)s ungelesene Benachrichtigung.",
"other": "Du hast %(count)s ungelesene Benachrichtigungen."
}
},
"menu": {
"hide": "Verstecken",
"hide_others": "Andere verstecken",
"services": "Dienste",
"unhide": "Wieder anzeigen"
},
"right_click_menu": {
"add_to_dictionary": "Wörterbuch hinzufügen",
"copy_email": "Email-Adresse kopieren",
"copy_image": "Bild kopieren",
"copy_image_url": "Bild-Adresse kopieren",
"copy_link_url": "Link-Adresse kopieren",
"save_image_as": "Bild speichern unter...",
"save_image_as_error_description": "Das Bild konnte nicht gespeichert werden",
"save_image_as_error_title": "Bild kann nicht gespeichert werden"
},
"store": {
"error": {
"backend_changed": "Daten löschen und neu laden?",
"backend_changed_detail": "Zugriff auf Schlüssel im Systemschlüsselbund nicht möglich, er scheint sich geändert zu haben.",
"backend_changed_title": "Datenbank konnte nicht geladen werden",
"backend_no_encryption": "Ihr System verfügt über einen unterstützten Keyring, aber die Verschlüsselung ist nicht verfügbar.",
"backend_no_encryption_detail": "Electron hat festgestellt, dass der Keyring von %(backend)s keine Verschlüsselung bietet. Wenn du den Keyring installiert hast, starte den Rechner bitte neu und versuche es erneut. Optional kann %(brand)s auch eine abgeschwächte Verschlüsselung nutzen.",
"backend_no_encryption_title": "Keine Verschlüsselungsunterstützung",
"unsupported_keyring": "Der Keyring des Systems wird nicht unterstützt. Daher kann die Datenbank nicht geöffnet werden kann.",
"unsupported_keyring_detail": "Die Keyring-Erkennung von Electron hat kein unterstütztes Backend gefunden. Du kannst einmalig versuchen, eine manuelle Konfiguration von %(brand)s über die Kommandozeile vorzunehmen. Infos unter %(link)s.",
"unsupported_keyring_title": "System nicht unterstützt",
"unsupported_keyring_use_basic_text": "Schwächere Verschlüsselung verwenden",
"unsupported_keyring_use_plaintext": "Verwende keine Verschlüsselung"
}
},
"view_menu": {
"actual_size": "Tatsächliche Größe",
"toggle_developer_tools": "Developer-Tools an/aus",
"toggle_full_screen": "Vollbildschirm an/aus",
"view": "Ansicht"
},
"window_menu": {
"bring_all_to_front": "Alles in den Vordergrund",
"label": "Fenster",
"zoom": "Zoomen"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"action": {
"cancel": "Ακύρωση",
"close": "Κλείσιμο",
"close_brand": "Κλείσιμο %(brand)s",
"copy": "Αντιγραφή",
"cut": "Αποκοπή",
"delete": "Διαγραφή",
"edit": "Επεξεργασία",
"minimise": "Ελαχιστοποίηση",
"paste": "Επικόλληση",
"paste_match_style": "Επικόλληση και Ταίριασμα Στυλ",
"quit": "Κλείσιμο",
"redo": "Επανάληψη",
"select_all": "Επιλογή Όλων",
"show_hide": "Eμφάνιση/Απόκρυψη",
"undo": "Αναίρεση",
"zoom_in": "Μεγέθυνση",
"zoom_out": "Σμίκρυνση"
},
"common": {
"about": "Σχετικά με",
"brand_help": "%(brand)s Υποστήριξη",
"help": "Βοήθεια",
"preferences": "Προτιμήσεις"
},
"confirm_quit": "Είστε βέβαιος ότι θέλετε να εγκαταλείψετε;",
"edit_menu": {
"speech": "Ομιλία",
"speech_start_speaking": "Ξεκινήστε να μιλάτε",
"speech_stop_speaking": "Τερματίστε να μιλάτε"
},
"file_menu": {
"label": "Αρχείο"
},
"menu": {
"hide": "Απόκρυψη",
"hide_others": "Απόκρυψη Άλλων",
"services": "Υπηρεσίες",
"unhide": "Εμφάνιση"
},
"right_click_menu": {
"add_to_dictionary": "Προσθήκη στο λεξικό",
"copy_email": "Αντιγραφή διεύθυνσης email",
"copy_image": "Αντιγραφή εικόνας",
"copy_image_url": "Αντιγραφή διεύθυνσης εικόνας",
"copy_link_url": "Αντιγραφή διεύθυνσης συνδέσμου",
"save_image_as": "Αποθήκευση εικόνας ως...",
"save_image_as_error_description": "Η αποθήκευση της εικόνας απέτυχε",
"save_image_as_error_title": "Αποτυχία αποθήκευσης εικόνας"
},
"view_menu": {
"actual_size": "Πραγματικό Μέγεθος",
"toggle_developer_tools": "Άνοιγμα Εργαλείων Προγραμματιστή",
"toggle_full_screen": "Εναλλαγή σε Πλήρη Οθόνη",
"view": "Προβολή"
},
"window_menu": {
"bring_all_to_front": "Μεταφορά Όλων στο Προσκήνιο",
"label": "Παράθυρο",
"zoom": "Ζουμ"
}
}
+92
View File
@@ -0,0 +1,92 @@
{
"action": {
"cancel": "Cancel",
"close": "Close",
"close_brand": "Close %(brand)s",
"copy": "Copy",
"cut": "Cut",
"delete": "Delete",
"edit": "Edit",
"minimise": "Minimize",
"paste": "Paste",
"paste_match_style": "Paste and Match Style",
"quit": "Quit",
"redo": "Redo",
"select_all": "Select All",
"show_hide": "Show/Hide",
"undo": "Undo",
"zoom_in": "Zoom In",
"zoom_out": "Zoom Out"
},
"common": {
"about": "About",
"brand_help": "%(brand)s Help",
"help": "Help",
"no": "No",
"preferences": "Preferences",
"yes": "Yes"
},
"confirm_quit": "Are you sure you want to quit?",
"edit_menu": {
"speech": "Speech",
"speech_start_speaking": "Start Speaking",
"speech_stop_speaking": "Stop Speaking"
},
"eol": {
"no_more_updates": "You are running an unsupported version of macOS. Please upgrade to receive %(brand)s updates.",
"title": "System unsupported",
"warning": "You are running an unsupported version of macOS. Please upgrade to ensure %(brand)s keeps working."
},
"file_menu": {
"label": "File"
},
"icon_overlay": {
"description_error": "Error",
"description_notifications": {
"one": "You have %(count)s unread notification.",
"other": "You have %(count)s unread notifications."
}
},
"menu": {
"hide": "Hide",
"hide_others": "Hide Others",
"services": "Services",
"unhide": "Unhide"
},
"right_click_menu": {
"add_to_dictionary": "Add to dictionary",
"copy_email": "Copy email address",
"copy_image": "Copy image",
"copy_image_url": "Copy image address",
"copy_link_url": "Copy link address",
"save_image_as": "Save image as...",
"save_image_as_error_description": "The image failed to save",
"save_image_as_error_title": "Failed to save image"
},
"store": {
"error": {
"backend_changed": "Clear data and reload?",
"backend_changed_detail": "Unable to access secret from system keyring, it appears to have changed.",
"backend_changed_title": "Failed to load database",
"backend_no_encryption": "Your system has a supported keyring but encryption is not available.",
"backend_no_encryption_detail": "Electron has detected that encryption is not available on your keyring %(backend)s. Please ensure that you have the keyring installed. If you do have the keyring installed, please reboot and try again. Optionally, you can allow %(brand)s to use a weaker form of encryption.",
"backend_no_encryption_title": "No encryption support",
"unsupported_keyring": "Your system has an unsupported keyring meaning the database cannot be opened.",
"unsupported_keyring_detail": "Electron's keyring detection did not find a supported backend. You can attempt to manually configure the backend by starting %(brand)s with a command-line argument, a one-time operation. See %(link)s.",
"unsupported_keyring_title": "System unsupported",
"unsupported_keyring_use_basic_text": "Use weaker encryption",
"unsupported_keyring_use_plaintext": "Use no encryption"
}
},
"view_menu": {
"actual_size": "Actual Size",
"toggle_developer_tools": "Toggle Developer Tools",
"toggle_full_screen": "Toggle Full Screen",
"view": "View"
},
"window_menu": {
"bring_all_to_front": "Bring All to Front",
"label": "Window",
"zoom": "Zoom"
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"action": {
"cancel": "Nuligi",
"close": "Fermi",
"close_brand": "Fermu %(brand)s",
"copy": "Kopii",
"cut": "Tranĉi",
"delete": "Forigi",
"edit": "Redakti",
"minimise": "Minimumigi",
"paste": "Enmeti",
"redo": "Refari",
"select_all": "Elekti Ĉiujn",
"show_hide": "Montri/Kaŝi",
"undo": "Malfari"
},
"common": {
"about": "Prio",
"help": "Helpo",
"preferences": "Agordoj"
},
"confirm_quit": "Ĉu vi certas, ke vi volas ĉesi?",
"edit_menu": {
"speech_start_speaking": "Ekparoli",
"speech_stop_speaking": "Ĉesi Paroli"
},
"file_menu": {
"label": "Dosiero"
},
"menu": {
"hide": "Kaŝi",
"hide_others": "Kaŝi Aliajn",
"unhide": "Malkaŝi"
},
"right_click_menu": {
"copy_email": "Kopiu retadreson",
"copy_image": "Kopiu bildon",
"copy_image_url": "Kopiu adreson de la bildo",
"copy_link_url": "Kopiu ligilon de la bildo",
"save_image_as_error_description": "La bildo malsukcesis elŝutiĝi",
"save_image_as_error_title": "Malsukcesis elŝuti bildon"
},
"view_menu": {
"toggle_developer_tools": "Baskuligi Programistajn Ilojn",
"view": "Vidi"
},
"window_menu": {
"label": "Fenestro"
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"action": {
"cancel": "Cancelar",
"close": "Cerrar",
"close_brand": "Cerrar %(brand)s",
"copy": "Copiar",
"cut": "Cortar",
"delete": "Borrar",
"edit": "Editar",
"minimise": "Minimizar",
"paste": "Pegar",
"paste_match_style": "Pegar manteniendo estilo",
"quit": "Salir",
"redo": "Rehacer",
"select_all": "Seleccionar todo",
"show_hide": "Ver/Ocultar",
"undo": "Deshacer",
"zoom_in": "Acercar",
"zoom_out": "Alejar"
},
"common": {
"about": "Acerca de",
"brand_help": "Ayuda sobre %(brand)s",
"help": "Ayuda",
"no": "No",
"preferences": "Preferencias",
"yes": "Sí"
},
"confirm_quit": "¿Quieres salir?",
"edit_menu": {
"speech": "Dictado",
"speech_start_speaking": "Empezar a hablar",
"speech_stop_speaking": "Parar de hablar"
},
"file_menu": {
"label": "Archivo"
},
"menu": {
"hide": "Ocultar",
"hide_others": "Ocultar otros",
"services": "Servicios",
"unhide": "Mostrar"
},
"right_click_menu": {
"add_to_dictionary": "Añadir al diccionario",
"copy_email": "Copiar dirección de correo",
"copy_image": "Copiar imagen",
"copy_image_url": "Copiar dirección de la imagen",
"copy_link_url": "Copiar dirección de enlace",
"save_image_as": "Guardar imagen como...",
"save_image_as_error_description": "La imagen no se ha podido guardar",
"save_image_as_error_title": "No se ha podido guardar la imagen"
},
"view_menu": {
"actual_size": "Tamaño real",
"toggle_developer_tools": "Abrir/cerrar herramientas de desarrollo",
"toggle_full_screen": "Entrar/salir de pantalla completa",
"view": "Ver"
},
"window_menu": {
"bring_all_to_front": "Traer todas al primer plano",
"label": "Ventana",
"zoom": "Acercamiento"
}
}
+92
View File
@@ -0,0 +1,92 @@
{
"action": {
"cancel": "Loobu",
"close": "Sulge",
"close_brand": "Sulge %(brand)s",
"copy": "Kopeeri",
"cut": "Lõika",
"delete": "Kustuta",
"edit": "Muuda",
"minimise": "Vähenda",
"paste": "Aseta",
"paste_match_style": "Aseta kasutades sama stiili",
"quit": "Välju",
"redo": "Tee uuesti",
"select_all": "Vali kõik",
"show_hide": "Näita/peida",
"undo": "Võta tagasi",
"zoom_in": "Suurenda",
"zoom_out": "Vähenda"
},
"common": {
"about": "Rakenduse teave",
"brand_help": "%(brand)s abiteave",
"help": "Abiteave",
"no": "Ei",
"preferences": "Eelistused",
"yes": "Jah"
},
"confirm_quit": "Kas sa kindlasti soovid rakendusest väljuda?",
"edit_menu": {
"speech": "Kõne",
"speech_start_speaking": "Alusta rääkimist",
"speech_stop_speaking": "Lõpeta rääkimine"
},
"eol": {
"no_more_updates": "Sa kasutad macOS-i toetamata versiooni. %(brand)s rakenduse tulevaste versioonide kasutamiseks palun uuenda operatsioonisüsteemi.",
"title": "Süsteem pole toetatud",
"warning": "Sa kasutad macOS-i toetamata versiooni. Et %(brand)s toimiks ka edaspidi, palun uuenda operatsioonisüsteemi."
},
"file_menu": {
"label": "Fail"
},
"icon_overlay": {
"description_error": "Viga",
"description_notifications": {
"one": "Sul on %(count)s lugemata teavitus",
"other": "Sul on %(count)s lugemata teavitust"
}
},
"menu": {
"hide": "Peida",
"hide_others": "Peida muud",
"services": "Teenused",
"unhide": "Näita uuesti"
},
"right_click_menu": {
"add_to_dictionary": "Lisa sõnastikku",
"copy_email": "Kopeeri e-posti aadress",
"copy_image": "Kopeeri pilt",
"copy_image_url": "Kopeeri pildi aadress",
"copy_link_url": "Kopeeri lingi aadress",
"save_image_as": "Salvesta pilt kui...",
"save_image_as_error_description": "Seda pilti ei õnnestunud salvestada",
"save_image_as_error_title": "Pildi salvestamine ei õnnestunud"
},
"store": {
"error": {
"backend_changed": "Kas kustutame andmed ja laadime uuesti?",
"backend_changed_detail": "Süsteemsest võtmerõngast ei õnnestu laadida vajalikku saladust, tundub et ta on muutunud.",
"backend_changed_title": "Andmebaasi ei õnnestunud laadida",
"backend_no_encryption": "Sinu süsteem kasutab toetatud võtmerõngast, kuid krüptimist pole saadaval.",
"backend_no_encryption_detail": "Electron on tuvastanud, et krüptimine pole sinu %(backend)s võtmerõnga jaoks saadaval. Palun kontrolli, et võtmerõngas oleks korrektselt paigaldatud. Kui sul on võtmerõngas paigaldatud, siis palun taaskäivita ta ja proovi uuesti. Lisavõimalusena saad lubada, et %(brand)s kasutab nõrgemat krüptimislahendust.",
"backend_no_encryption_title": "Krüptimise tugi puudub",
"unsupported_keyring": "Sinu süsteemis on kasutusel mittetoetatud võtmerõnga versioon ning see tähendab, et andmebaasi ei saa avada.",
"unsupported_keyring_detail": "Electroni võtmerõnga tuvastamine ei leidnud toetatud taustateenust. Kui käivitad rakenduse %(brand)s käsurealt õigete argumentidega, siis võib taustateenuse käsitsi seadistamine õnnestuda ning seda tegevust peaksid vaid üks kord tegema. Lisateave: %(link)s.",
"unsupported_keyring_title": "Süsteem pole toetatud",
"unsupported_keyring_use_basic_text": "Kasuta nõrgemat krüptimist",
"unsupported_keyring_use_plaintext": "Ära üldse kasuta krüptimist"
}
},
"view_menu": {
"actual_size": "Näita tavasuuruses",
"toggle_developer_tools": "Arendaja töövahendid sisse/välja",
"toggle_full_screen": "Täisekraanivaade sisse/välja",
"view": "Näita"
},
"window_menu": {
"bring_all_to_front": "Too kõik esiplaanile",
"label": "Aken",
"zoom": "Suumi"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"action": {
"cancel": "لغو",
"close": "بستن",
"close_brand": "بستن %(brand)s",
"copy": "رونوشت",
"cut": "برش",
"delete": "پاک‌کردن",
"edit": "ویرایش",
"minimise": "کمینه",
"paste": "جای‌گذاری",
"paste_match_style": "جای‌گذاری و تطبیق سَبک",
"quit": "خروج",
"redo": "انجام دوباره",
"select_all": "گزینش همه",
"show_hide": "نمایش/پنهان",
"undo": "بازگردانی",
"zoom_in": "بزرگنمایی به داخل",
"zoom_out": "بزرگنمایی به خارج"
},
"common": {
"about": "درباره",
"brand_help": "کمک %(brand)s",
"help": "راهنما",
"preferences": "ترجیحات"
},
"confirm_quit": "آیا مطمئنید که می‌خواهید خارج شوید؟",
"edit_menu": {
"speech": "صحبت کردن",
"speech_start_speaking": "صحبت کردن را شروع کنید",
"speech_stop_speaking": "صحبت کردن را تمام کنید"
},
"file_menu": {
"label": "پرونده"
},
"menu": {
"hide": "پنهان",
"hide_others": "پنهان کردن دیگران",
"services": "خدمات",
"unhide": "آشکار"
},
"right_click_menu": {
"add_to_dictionary": "افزودن به لغت‌نامه",
"copy_email": "رونوشت نشانی رایانامه",
"copy_image": "رونوشت تصویر",
"copy_image_url": "رونوشت نشانی تصویر",
"copy_link_url": "رونوشت نشانی پیوند",
"save_image_as": "ذخیرهٔ تصویر به عنوان...",
"save_image_as_error_description": "تصویر ذخیره نشد",
"save_image_as_error_title": "ذخیرهٔ تصویر شکست خورد"
},
"view_menu": {
"actual_size": "اندازهٔ واقعی",
"toggle_developer_tools": "تغییر وضعیت ابزارهای توسعه‌دهنده",
"toggle_full_screen": "تغییر وضعیت تمام‌صفحه",
"view": "مشاهده"
},
"window_menu": {
"bring_all_to_front": "همه را به جلو بیاورید",
"label": "پنجره",
"zoom": "بزرگنمایی"
}
}
+71
View File
@@ -0,0 +1,71 @@
{
"action": {
"cancel": "Peruuta",
"close": "Sulje",
"close_brand": "Sulje %(brand)s",
"copy": "Kopioi",
"cut": "Leikkaa",
"delete": "Poista",
"edit": "Muokkaa",
"minimise": "Pienennä",
"paste": "Liitä",
"paste_match_style": "Liitä ja sovita tyyli",
"quit": "Lopeta",
"redo": "Tee uudestaan",
"select_all": "Valitse kaikki",
"show_hide": "Näytä/piilota",
"undo": "Peru",
"zoom_in": "Suurenna",
"zoom_out": "Pienennä"
},
"common": {
"about": "Tietoa",
"brand_help": "%(brand)s-tuki",
"help": "Ohje",
"no": "Ei",
"preferences": "Valinnat",
"yes": "Kyllä"
},
"confirm_quit": "Haluatko varmasti poistua?",
"edit_menu": {
"speech": "Puhe",
"speech_start_speaking": "Aloita puhe",
"speech_stop_speaking": "Lopeta puhe"
},
"file_menu": {
"label": "Tiedosto"
},
"menu": {
"hide": "Piilota",
"hide_others": "Piilota muut",
"services": "Palvelut",
"unhide": "Palauta näkyviin"
},
"right_click_menu": {
"add_to_dictionary": "Lisää sanakirjaan",
"copy_email": "Kopioi sähköpostiosoite",
"copy_image": "Kopioi kuva",
"copy_image_url": "Kopioi kuvan osoite",
"copy_link_url": "Kopioi linkin osoite",
"save_image_as": "Tallenna kuva nimellä...",
"save_image_as_error_description": "Kuvan tallennus epäonnistui",
"save_image_as_error_title": "Kuvan tallennus epäonnistui"
},
"store": {
"error": {
"backend_changed_title": "Tietokannan lataaminen epäonnistui",
"unsupported_keyring_title": "Järjestelmä ei ole tuettu"
}
},
"view_menu": {
"actual_size": "Alkuperäinen koko",
"toggle_developer_tools": "Näytä tai piilota kehittäjätyökalut",
"toggle_full_screen": "Vaihda koko näytön tilaa",
"view": "Näytä"
},
"window_menu": {
"bring_all_to_front": "Tuo kaikki eteen",
"label": "Ikkuna",
"zoom": "Suurennus"
}
}
+92
View File
@@ -0,0 +1,92 @@
{
"action": {
"cancel": "Annuler",
"close": "Fermer",
"close_brand": "Fermer %(brand)s",
"copy": "Copier",
"cut": "Couper",
"delete": "Supprimer",
"edit": "Modifier",
"minimise": "Minimiser",
"paste": "Coller",
"paste_match_style": "Copier avec le style de destination",
"quit": "Quitter",
"redo": "Refaire",
"select_all": "Tout sélectionner",
"show_hide": "Afficher/Masquer",
"undo": "Annuler",
"zoom_in": "Zoomer",
"zoom_out": "Dé-zoomer"
},
"common": {
"about": "À propos",
"brand_help": "Aide de %(brand)s",
"help": "Aide",
"no": "Non",
"preferences": "Préférences",
"yes": "Oui"
},
"confirm_quit": "Êtes-vous sûr de vouloir quitter ?",
"edit_menu": {
"speech": "Dictée",
"speech_start_speaking": "Commencer la dictée",
"speech_stop_speaking": "Arrêter la dictée"
},
"eol": {
"no_more_updates": "Vous utilisez une version de macOS non prise en charge. Veuillez mettre à jour macOS pour recevoir les mises à jour de %(brand)s.",
"title": "Système non pris en charge",
"warning": "Vous utilisez une version de macOS non prise en charge. Veuillez mettre à jour macOS afin que %(brand)s continue de fonctionner."
},
"file_menu": {
"label": "Fichier"
},
"icon_overlay": {
"description_error": "Erreur",
"description_notifications": {
"one": "Vous avez %(count)s notification non lue.",
"other": "Vous avez %(count)s notifications non lues."
}
},
"menu": {
"hide": "Masquer",
"hide_others": "Masquer les autres",
"services": "Services",
"unhide": "Dé-masquer"
},
"right_click_menu": {
"add_to_dictionary": "Ajouter au dictionnaire",
"copy_email": "Copier ladresse e-mail",
"copy_image": "Copier limage",
"copy_image_url": "Copier l'adresse de l'image",
"copy_link_url": "Copier ladresse du lien",
"save_image_as": "Enregistrer limage sous…",
"save_image_as_error_description": "Limage na pas pu être enregistrée",
"save_image_as_error_title": "Échec de la sauvegarde de limage"
},
"store": {
"error": {
"backend_changed": "Effacer les données et recharger ?",
"backend_changed_detail": "Impossible d'accéder aux secrets depuis le trousseau de clés du système, il semble avoir changé.",
"backend_changed_title": "Impossible de charger la base de données",
"backend_no_encryption": "Votre système dispose d'un trousseau de clés compatible mais le chiffrement est indisponible.",
"backend_no_encryption_detail": "Electron a détecté que le chiffrement n'est pas disponible pour le trousseau %(backend)s. Veuillez vérifier que votre trousseau est installé. Si c'est le cas, redémarrez et réessayez. Comme alternative, vous pouvez autoriser %(brand)s à utiliser une option de chiffrement moins sécurisée.",
"backend_no_encryption_title": "Aucune prise en charge du chiffrement",
"unsupported_keyring": "Votre système possède un trousseau de clés non pris en charge, la base de données ne peut pas être ouverte.",
"unsupported_keyring_detail": "La détection du porte-clés par Electron n'a pas permis de trouver de backend compatible. Vous pouvez essayer de configurer manuellement le backend en utilisant %(brand)s avec un argument de ligne de commande. Cette opération doit être effectuer une seule fois. Voir%(link)s.",
"unsupported_keyring_title": "Système non pris en charge",
"unsupported_keyring_use_basic_text": "Utiliser un chiffrement plus faible",
"unsupported_keyring_use_plaintext": "N'utilise pas de chiffrement"
}
},
"view_menu": {
"actual_size": "Taille réelle",
"toggle_developer_tools": "Basculer les outils de développement",
"toggle_full_screen": "Basculer le plein écran",
"view": "Afficher"
},
"window_menu": {
"bring_all_to_front": "Tout amener au premier plan",
"label": "Fenêtre",
"zoom": "Zoom"
}
}
+60
View File
@@ -0,0 +1,60 @@
{
"action": {
"cancel": "Cancelar",
"close": "Pechar",
"copy": "Copiar",
"cut": "Cortar",
"delete": "Eliminar",
"edit": "Editar",
"minimise": "Minimizar",
"paste": "Pegar",
"paste_match_style": "Pegar e imitar estilo",
"quit": "Saír",
"redo": "Refacer",
"select_all": "Elexir todo",
"show_hide": "Mostrar/Agochar",
"undo": "Desfacer",
"zoom_in": "Aumentar",
"zoom_out": "Diminuir"
},
"common": {
"about": "Acerca de",
"help": "Axuda",
"preferences": "Preferencias"
},
"confirm_quit": "Tes a certeza de que queres saír?",
"edit_menu": {
"speech": "Falar",
"speech_start_speaking": "Comeza a falar",
"speech_stop_speaking": "Deixa de falar"
},
"file_menu": {
"label": "Ficheiro"
},
"menu": {
"hide": "Agochar",
"hide_others": "Agochar outras",
"services": "Servizos",
"unhide": "Desagochar"
},
"right_click_menu": {
"add_to_dictionary": "Engadir ao dicionario",
"copy_email": "Copiar enderezo de email",
"copy_image": "Copiar imaxe",
"copy_link_url": "Copiar enderezo da ligazón",
"save_image_as": "Gardar imaxe como...",
"save_image_as_error_description": "Non se gardou a imaxe",
"save_image_as_error_title": "Fallou o gardado da imaxe"
},
"view_menu": {
"actual_size": "Tamaño real",
"toggle_developer_tools": "Activar ferramentas de desenvolvemento",
"toggle_full_screen": "Activar pantalla completa",
"view": "Vista"
},
"window_menu": {
"bring_all_to_front": "Traer todo á fronte",
"label": "Ventá",
"zoom": "Aumento"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"action": {
"cancel": "ביטול",
"close": "סגור",
"close_brand": "סגור%(brand)s",
"copy": "העתק",
"cut": "גזור",
"delete": "מחק",
"edit": "ערוך",
"minimise": "מזער",
"paste": "הדבק",
"paste_match_style": "הדבק והתאם סגנון",
"quit": "יציאה",
"redo": "בצע שוב",
"select_all": "בחר הכל",
"show_hide": "הצג\\הסתר",
"undo": "בטל ביצוע",
"zoom_in": "התקרב",
"zoom_out": "התרחק"
},
"common": {
"about": "אודות",
"brand_help": "%(brand)s עזרה",
"help": "עזרה",
"preferences": "העדפות"
},
"confirm_quit": "האם אתה בטוח שברצונך לצאת?",
"edit_menu": {
"speech": "דיבור",
"speech_start_speaking": "התחל לדבר",
"speech_stop_speaking": "הפסק לדבר"
},
"file_menu": {
"label": "קובץ"
},
"menu": {
"hide": "הסתר",
"hide_others": "הסתר אחרים",
"services": "שרותים",
"unhide": "בטל הסתרה"
},
"right_click_menu": {
"add_to_dictionary": "הוסף למילון",
"copy_email": "העתק כתובת אימייל",
"copy_image": "העתק תמונה",
"copy_image_url": "העתקת כתובת התמונה",
"copy_link_url": "העתק קישור",
"save_image_as": "שמור תמונה בשם...",
"save_image_as_error_description": "שמירת התמונה נכשלה",
"save_image_as_error_title": "נכשל בשמירת התמונה"
},
"view_menu": {
"actual_size": "גודל ממשי",
"toggle_developer_tools": "הפעל כלי מפתחים",
"toggle_full_screen": "הפעל מצב מסך מלא",
"view": "צפה"
},
"window_menu": {
"bring_all_to_front": "הבא הכל לחזית",
"label": "חלון",
"zoom": "גודל תצוגה"
}
}
+93
View File
@@ -0,0 +1,93 @@
{
"action": {
"cancel": "Otkazati",
"close": "Zatvori",
"close_brand": "Zatvori %(brand)s",
"copy": "Kopirati",
"cut": "Izrezati",
"delete": "Izbriši",
"edit": "Uredi",
"minimise": "Minimiziraj",
"paste": "Zalijepiti",
"paste_match_style": "Zalijepi i uskladi stil",
"quit": "Prestati",
"redo": "Preurediti",
"select_all": "Odaberi sve",
"show_hide": "Pokaži/sakrij",
"undo": "Poništi",
"zoom_in": "Povećaj",
"zoom_out": "Smanji"
},
"common": {
"about": "Više o",
"brand_help": "%(brand)s pomoć",
"help": "Pomoć",
"no": "Ne",
"preferences": "Preference",
"yes": "Da"
},
"confirm_quit": "Jesi li siguran da želiš odustati?",
"edit_menu": {
"speech": "Govor",
"speech_start_speaking": "Počnite govoriti",
"speech_stop_speaking": "Prestanite govoriti"
},
"eol": {
"no_more_updates": "Upotrebljavate nepodržanu inačicu macOS-a. Nadogradite kako biste primili ažuriranja za %(brand)s.",
"title": "Sustav nije podržan",
"warning": "Upotrebljavate nepodržanu inačicu macOS-a. Nadogradite kako biste osigurali da %(brand)s nastavi s radom."
},
"file_menu": {
"label": "Datoteka"
},
"icon_overlay": {
"description_error": "Pogreška",
"description_notifications": {
"one": "Imate %(count)s nepročitanu obavijest.",
"few": "Imate %(count)s nepročitane obavijesti.",
"other": "Imate %(count)s nepročitanih obavijesti."
}
},
"menu": {
"hide": "Sakrij",
"hide_others": "Sakrij ostale",
"services": "Usluge",
"unhide": "Otkrij"
},
"right_click_menu": {
"add_to_dictionary": "Dodaj u rječnik",
"copy_email": "Kopiraj e-adresu",
"copy_image": "Kopiraj sliku",
"copy_image_url": "Kopiraj adresu slike",
"copy_link_url": "Kopiraj adresu poveznice",
"save_image_as": "Spremi sliku kao...",
"save_image_as_error_description": "Spremanje slike nije uspjelo",
"save_image_as_error_title": "Spremanje slike nije uspjelo"
},
"store": {
"error": {
"backend_changed": "Želite li izbrisati podatke i ponovno učitati?",
"backend_changed_detail": "Nije moguće pristupiti tajnom kodu iz pohrane ključeva sustava, izgleda da je promijenjen.",
"backend_changed_title": "Nije uspjelo učitavanje baze podataka",
"backend_no_encryption": "Vaš sustav ima podržanu pohranu ključeva, ali šifriranje nije dostupno.",
"backend_no_encryption_detail": "Electron je otkrio da šifriranje nije dostupno u vašoj pohrani ključeva %(backend)s. Provjerite imate li instaliranu pohranu ključeva. Ako imate, ponovno pokrenite računalo i pokušajte ponovno. Po želji možete dopustiti da %(brand)s upotrebljava slabiji oblik šifriranja.",
"backend_no_encryption_title": "Nema podrške za šifriranje",
"unsupported_keyring": "Vaš sustav ima nepodržanu pohranu ključeva, što znači da se baza podataka ne može otvoriti.",
"unsupported_keyring_detail": "Electronova detekcija pohrane ključeva nije pronašla podržanu pozadinu. Možete pokušati ručno konfigurirati pozadinu tako da pokrenete %(brand)s s argumentom naredbenog retka, što je potrebno učiniti samo jednom. Pogledajte %(link)s.",
"unsupported_keyring_title": "Sustav nije podržan",
"unsupported_keyring_use_basic_text": "Upotrijebi slabije šifriranje",
"unsupported_keyring_use_plaintext": "Ne upotrebljavaj šifriranje"
}
},
"view_menu": {
"actual_size": "Stvarna veličina",
"toggle_developer_tools": "Uključi/isključi alate za razvojne inženjere",
"toggle_full_screen": "Uključi/isključi prikaz na cijelom zaslonu",
"view": "Prikaz"
},
"window_menu": {
"bring_all_to_front": "Stavi sve u prvi plan",
"label": "Prozor",
"zoom": "Zumiranje"
}
}
+92
View File
@@ -0,0 +1,92 @@
{
"action": {
"cancel": "Mégse",
"close": "Bezárás",
"close_brand": "Az %(brand)s bezárása",
"copy": "Másolás",
"cut": "Kivágás",
"delete": "Törlés",
"edit": "Szerkesztés",
"minimise": "Lecsukás",
"paste": "Beillesztés",
"paste_match_style": "Beillesztés formázással",
"quit": "Kilépés",
"redo": "Újra",
"select_all": "Összes kijelölése",
"show_hide": "Megjelenítés/elrejtés",
"undo": "Visszavonás",
"zoom_in": "Nagyítás",
"zoom_out": "Kicsinyítés"
},
"common": {
"about": "Névjegy",
"brand_help": "%(brand)s Súgó",
"help": "Súgó",
"no": "Nem",
"preferences": "Beállítások",
"yes": "Igen"
},
"confirm_quit": "Biztos, hogy kilép?",
"edit_menu": {
"speech": "Beszéd",
"speech_start_speaking": "Kezdjen beszélni",
"speech_stop_speaking": "Fejezze be a beszédet"
},
"eol": {
"no_more_updates": "A macOS egy nem támogatott verzióját futtatja. Frissítse, hogy megkapja az %(brand)s új frissítéseit.",
"title": "A rendszer nem támogatott",
"warning": "A macOS egy nem támogatott verzióját futtatja. Frissítse a rendszert, hogy biztosítsa az %(brand)s további működését."
},
"file_menu": {
"label": "Fájl"
},
"icon_overlay": {
"description_error": "Hiba",
"description_notifications": {
"one": "%(count)s olvasatlan értesítése van.",
"other": "%(count)s olvasatlan értesítése van."
}
},
"menu": {
"hide": "Elrejtés",
"hide_others": "Mások elrejtése",
"services": "Szolgáltatás",
"unhide": "Felfedés"
},
"right_click_menu": {
"add_to_dictionary": "Hozzáadás a szótárhoz",
"copy_email": "E-mail-cím másolása",
"copy_image": "Kép másolása",
"copy_image_url": "Kép címének másolása",
"copy_link_url": "Hivatkozás másolása",
"save_image_as": "Kép mentése másként…",
"save_image_as_error_description": "A kép mentése sikertelen",
"save_image_as_error_title": "Kép mentése sikertelen"
},
"store": {
"error": {
"backend_changed": "Adatok törlése és újratöltés?",
"backend_changed_detail": "Nem sikerült hozzáférni a rendszerkulcstartó titkos kódjához, úgy tűnik, megváltozott.",
"backend_changed_title": "Nem sikerült betölteni az adatbázist",
"backend_no_encryption": "A rendszer támogatott kulcstartóval rendelkezik, de a titkosítás nem érhető el.",
"backend_no_encryption_detail": "Az Electron észlelte, hogy a titkosítás nem érhető el a(z) %(backend)s kulcstartóján. Győződjön meg róla, hogy telepítve van-e a kulcstartó. Ha telepítve van, indítsa újra és próbálja újra. Esetleg engedélyezheti a gyengébb titkosítást az %(brand)s számára.",
"backend_no_encryption_title": "Nincs titkosítási támogatás",
"unsupported_keyring": "A rendszer nem támogatott kulcstartóval rendelkezik, ami azt jelenti, hogy az adatbázis nem nyitható meg.",
"unsupported_keyring_detail": "Az Electron kulcstartóészlelése nem talált támogatott háttérrendszert. Megpróbálhatja kézileg beállítani a háttérrendszert az %(brand)s egyszeri, parancssori argumentummal való indításával. Lásd: %(link)s.",
"unsupported_keyring_title": "A rendszer nem támogatott",
"unsupported_keyring_use_basic_text": "Gyengébb titkosítás használata",
"unsupported_keyring_use_plaintext": "Ne használjon titkosítást"
}
},
"view_menu": {
"actual_size": "Jelenlegi méret",
"toggle_developer_tools": "Fejlesztői eszközök",
"toggle_full_screen": "Teljes képernyő",
"view": "Megtekintés"
},
"window_menu": {
"bring_all_to_front": "Minden előtérbe hozása",
"label": "Ablak",
"zoom": "Nagyítás"
}
}
+87
View File
@@ -0,0 +1,87 @@
{
"action": {
"cancel": "Չեղարկել",
"close": "Փակել",
"close_brand": "Փակել %(brand)s",
"copy": "Պատճենել",
"cut": "Կտրել",
"delete": "Ջնջել",
"edit": "Խմբագրել",
"minimise": "Նվազագույնի հասցնել",
"paste": "Տեղադրել",
"paste_match_style": "Տեղադրել և համապատասխանեցնել ոճը",
"quit": "Դուրս գալ",
"redo": "Կրկնել",
"select_all": "Ընտրել բոլորը",
"show_hide": "Ցուցադրել/Թաքցնել",
"undo": "Հետարկել",
"zoom_in": "Մեծացնել",
"zoom_out": "Փոքրացնել"
},
"common": {
"about": "Կենսագրություն",
"brand_help": "%(brand)s Օգնություն",
"help": "Օգնություն",
"no": "Ոչ",
"preferences": "Նախապատվություններ",
"yes": "Այո"
},
"confirm_quit": "Վստա՞հ եք, որ ուզում եք դուրս գալ։",
"edit_menu": {
"speech": "Խոսք/Ելույթ",
"speech_start_speaking": "Սկսեք խոսել",
"speech_stop_speaking": "Դադարեցրեք խոսելը"
},
"file_menu": {
"label": "Ֆայլ"
},
"icon_overlay": {
"description_error": "Սխալ",
"description_notifications": {
"one": "Դուք ունեք %(count)s չկարդացված ծանուցում։",
"other": "Դուք ունեք %(count)s չկարդացված ծանուցումներ։"
}
},
"menu": {
"hide": "Թաքցնել",
"hide_others": "Թաքցնել մյուսները",
"services": "Ծառայություններ",
"unhide": "Ապաթաքցնել"
},
"right_click_menu": {
"add_to_dictionary": "Ավելացնել բառարանում",
"copy_email": "Պատճենել էլ․ հասցեն",
"copy_image": "Պատճենել պատկերը",
"copy_image_url": "Պատճենել պատկերի հասցեն",
"copy_link_url": "Պատճենել հղման հասցեն",
"save_image_as": "Պահպանել պատկերը որպես...",
"save_image_as_error_description": "Պատկերը չհաջողվեց պահպանել",
"save_image_as_error_title": "Չհաջողվեց պահպանել պատկերը"
},
"store": {
"error": {
"backend_changed": "Մաքրե՞լ տվյալները և վերաբեռնե՞լ",
"backend_changed_detail": "Գաղտնի տվյալները հասանելի չեն համակարգի բանալիների պահոցից(keyring), կարծես թե այն փոխվել է։",
"backend_changed_title": "Չհաջողվեց բեռնել տվյալների բազան",
"backend_no_encryption": "Ձեր համակարգը ունի աջակցվող բանալիների պահոց, բայց գաղտնագրումը հասանելի չէ։",
"backend_no_encryption_detail": "Electron-ը հայտնաբերել է, որ ձեր բանալիների պահոցում %(backend)s գաղտնագրումը հասանելի չէ։ Խնդրում ենք համոզվել, որ բանալիների պահոցը տեղադրված է։ Եթե այն արդեն տեղադրված է, վերագործարկեք համակարգը և փորձեք կրկին։ Ցանկության դեպքում կարող եք թույլատրել %(brand)s-ին կիրառել ավելի թույլ գաղտնագրման տարբերակ։",
"backend_no_encryption_title": "Գաղտնագրման աջակցություն չկա",
"unsupported_keyring": "Համակարգում օգտագործվող բանալիների պահոցը(keyring) չի աջակցվում, ինչը նշանակում է, որ տվյալների բազան հնարավոր չէ բացել։",
"unsupported_keyring_detail": "Electron-ի բանալիների պահոցի ստուգումը չգտավ համատեղելի backend։ Կարող եք փորձել ձեռքով կարգավորել backend-ը` մեկնարկելով %(brand)s-ը command-line արգումենտով, մեկանգամյա գործողությամբ։ Տես %(link)s։",
"unsupported_keyring_title": "Համակարգը չի աջակցվում",
"unsupported_keyring_use_basic_text": "Օգտագործել ավելի թույլ գաղտնագրում",
"unsupported_keyring_use_plaintext": "Չօգտագործել գաղտնագրում"
}
},
"view_menu": {
"actual_size": "Իրական չափս",
"toggle_developer_tools": "Միացնել/անջատել ծրագրավորողի գործիքները",
"toggle_full_screen": "Միացնել/անջատել ամբողջական էկրանը",
"view": "Դիտել"
},
"window_menu": {
"bring_all_to_front": "Բերեք բոլորին առջևի պլան",
"label": "Պատուհան",
"zoom": "Մեծացնել"
}
}
+91
View File
@@ -0,0 +1,91 @@
{
"action": {
"cancel": "Batalkan",
"close": "Tutup",
"close_brand": "Tutuo %(brand)s",
"copy": "Salin",
"cut": "Potong",
"delete": "Hapus",
"edit": "Edit",
"minimise": "Minimalkan",
"paste": "Tempel",
"paste_match_style": "Tempel dan Cocokkan Gaya",
"quit": "Keluar",
"redo": "Ulangi",
"select_all": "Pilih Semua",
"show_hide": "Tampilkan/Sembunyikan",
"undo": "Urungkan",
"zoom_in": "Perbesar",
"zoom_out": "Perkecil"
},
"common": {
"about": "Tentang",
"brand_help": "Bantuan %(brand)s",
"help": "Bantuan",
"no": "Tidak",
"preferences": "Preferensi",
"yes": "Ya"
},
"confirm_quit": "Apakah Anda yakin ingin keluar?",
"edit_menu": {
"speech": "Dikte",
"speech_start_speaking": "Mulai Berbicara",
"speech_stop_speaking": "Berhenti Berbicara"
},
"eol": {
"no_more_updates": "Anda menggunakan versi macOS yang tidak didukung. Harap tingkatkan untuk menerima pembaruan %(brand)s.",
"title": "Sistem tidak didukung",
"warning": "Anda menggunakan versi macOS yang tidak didukung. Harap perbarui untuk memastikan%(brand)s terus bekerja."
},
"file_menu": {
"label": "Berkas"
},
"icon_overlay": {
"description_error": "Kesalahan",
"description_notifications": {
"other": "Anda memiliki %(count)s notifikasi yang belum dibaca."
}
},
"menu": {
"hide": "Sembunyikan",
"hide_others": "Sembunyikan yang Lain",
"services": "Layanan",
"unhide": "Tampilkan"
},
"right_click_menu": {
"add_to_dictionary": "Tambah ke kamus",
"copy_email": "Salin surel",
"copy_image": "Salin gambar",
"copy_image_url": "Salin alamat gambar",
"copy_link_url": "Salin alamat tautan",
"save_image_as": "Simpan gambar sebagai...",
"save_image_as_error_description": "Gambar gagal disimpan",
"save_image_as_error_title": "Gagal menyimpan gambar"
},
"store": {
"error": {
"backend_changed": "Hapus data dan muat ulang?",
"backend_changed_detail": "Tidak dapat mengakses rahasia dari keyring sistem, tampaknya telah berubah.",
"backend_changed_title": "Gagal memuat basis data",
"backend_no_encryption": "Sistem Anda memiliki keyring yang didukung tetapi enkripsi tidak tersedia.",
"backend_no_encryption_detail": "Electron telah mendeteksi bahwa enkripsi tidak tersedia pada keyring %(backend)s Anda. Harap pastikan bahwa Anda telah memasang keyring. Jika Anda telah memasang keyring, silakan mulai ulang dan coba lagi. Secara opsional, Anda dapat mengizinkan %(brand)s untuk menggunakan bentuk enkripsi yang lebih lemah.",
"backend_no_encryption_title": "Tidak ada dukungan enkripsi",
"unsupported_keyring": "Sistem Anda memiliki keyring yang tidak didukung yang berarti basis data tidak dapat dibuka.",
"unsupported_keyring_detail": "Deteksi keyring Electron tidak menemukan backend yang didukung. Anda dapat mencoba mengonfigurasi backend secara manual dengan memulai %(brand)s dengan argumen baris perintah, operasi satu kali. Lihat %(link)s.",
"unsupported_keyring_title": "Sistem tidak didukung",
"unsupported_keyring_use_basic_text": "Gunakan enkripsi yang lebih lemah",
"unsupported_keyring_use_plaintext": "Jangan gunakan enkripsi"
}
},
"view_menu": {
"actual_size": "Ukuran Sebenarnya",
"toggle_developer_tools": "Beralih Alat Pengembang",
"toggle_full_screen": "Beralih Layar Penuh",
"view": "Tampilan"
},
"window_menu": {
"bring_all_to_front": "Bawa Semua ke Depan",
"label": "Jendela",
"zoom": "Perbesar"
}
}
+62
View File
@@ -0,0 +1,62 @@
{
"action": {
"cancel": "Hætta við",
"close": "Loka",
"close_brand": "Loka %(brand)s",
"copy": "Afrita",
"cut": "Klippa",
"delete": "Eyða",
"edit": "Breyta",
"minimise": "Lágmarka",
"paste": "Líma",
"paste_match_style": "Líma og samsvara stíl",
"quit": "Hætta",
"redo": "Endurgera",
"select_all": "Velja allt",
"show_hide": "Sýna/Fela",
"undo": "Afturkalla",
"zoom_in": "Stækka",
"zoom_out": "Minnka"
},
"common": {
"about": "Um hugbúnaðinn",
"help": "Hjálp",
"preferences": "Stillingar"
},
"confirm_quit": "Ertu viss um að þú viljir hætta?",
"edit_menu": {
"speech": "Tal",
"speech_start_speaking": "Byrja tal",
"speech_stop_speaking": "Hætta tali"
},
"file_menu": {
"label": "Skrá"
},
"menu": {
"hide": "Fela",
"hide_others": "Fela aðra",
"services": "Þjónustur",
"unhide": "Birta"
},
"right_click_menu": {
"add_to_dictionary": "Bæta við orðasafn",
"copy_email": "Afrita tölvupóstfang",
"copy_image": "Afrita mynd",
"copy_image_url": "Afrita slóð myndar",
"copy_link_url": "Afrita vistfang tengils",
"save_image_as": "Vista mynd sem...",
"save_image_as_error_description": "Myndina var ekki hægt að vista",
"save_image_as_error_title": "Mistókst að vista mynd"
},
"view_menu": {
"actual_size": "Raunstærð",
"toggle_developer_tools": "Víxla forritunarverkfærum af/á",
"toggle_full_screen": "Víxla fullum skjá af/á",
"view": "Skoða"
},
"window_menu": {
"bring_all_to_front": "Setja allt fremst",
"label": "Gluggi",
"zoom": "Stærð"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"action": {
"cancel": "Annulla",
"close": "Chiudi",
"close_brand": "Chiudi %(brand)s",
"copy": "Copia",
"cut": "Taglia",
"delete": "Elimina",
"edit": "Modifica",
"minimise": "Riduci",
"paste": "Incolla",
"paste_match_style": "Incolla e adegua lo stile",
"quit": "Esci",
"redo": "Ripeti",
"select_all": "Seleziona tutto",
"show_hide": "Mostra/Nascondi",
"undo": "Annulla",
"zoom_in": "Ingrandisci",
"zoom_out": "Rimpicciolisci"
},
"common": {
"about": "Informazioni su",
"brand_help": "Aiuto per %(brand)s",
"help": "Aiuto",
"preferences": "Preferenze"
},
"confirm_quit": "Vuoi veramente uscire?",
"edit_menu": {
"speech": "Dettatura",
"speech_start_speaking": "Inizia a parlare",
"speech_stop_speaking": "Smetti di parlare"
},
"file_menu": {
"label": "File"
},
"menu": {
"hide": "Nascondi",
"hide_others": "Nascondi gli altri",
"services": "Servizi",
"unhide": "Mostra"
},
"right_click_menu": {
"add_to_dictionary": "Aggiungi al dizionario",
"copy_email": "Copia indirizzo email",
"copy_image": "Copia immagine",
"copy_image_url": "Copia indirizzo immagine",
"copy_link_url": "Copia indirizzo collegamento",
"save_image_as": "Salva immagine come...",
"save_image_as_error_description": "Non è stato possibile salvare l'immagine",
"save_image_as_error_title": "Salvataggio immagine fallito"
},
"view_menu": {
"actual_size": "Dimensione effettiva",
"toggle_developer_tools": "Attiva strumenti per sviluppatori",
"toggle_full_screen": "Passa a schermo intero",
"view": "Vedi"
},
"window_menu": {
"bring_all_to_front": "Porta tutto in primo piano",
"label": "Finestra",
"zoom": "Ingrandisci"
}
}
+62
View File
@@ -0,0 +1,62 @@
{
"action": {
"cancel": "キャンセル",
"close": "閉じる",
"close_brand": "%(brand)sを閉じる",
"copy": "コピー",
"cut": "切り取り",
"delete": "削除",
"edit": "編集",
"minimise": "最小化",
"paste": "貼り付け",
"paste_match_style": "スタイルを保持して貼り付け",
"quit": "終了",
"redo": "やり直す",
"select_all": "全て選択",
"show_hide": "表示/非表示",
"undo": "取り消す",
"zoom_in": "拡大",
"zoom_out": "縮小"
},
"common": {
"about": "概要",
"help": "ヘルプ",
"preferences": "環境設定"
},
"confirm_quit": "終了してよろしいですか?",
"edit_menu": {
"speech": "スピーチ",
"speech_start_speaking": "録音を開始",
"speech_stop_speaking": "録音を停止"
},
"file_menu": {
"label": "ファイル"
},
"menu": {
"hide": "非表示",
"hide_others": "他を非表示",
"services": "サービス",
"unhide": "再表示"
},
"right_click_menu": {
"add_to_dictionary": "辞書に追加",
"copy_email": "メールアドレスをコピー",
"copy_image": "画像をコピー",
"copy_image_url": "画像のアドレスをコピー",
"copy_link_url": "リンクのアドレスをコピー",
"save_image_as": "画像を保存",
"save_image_as_error_description": "画像の保存に失敗しました",
"save_image_as_error_title": "画像の保存に失敗"
},
"view_menu": {
"actual_size": "等倍",
"toggle_developer_tools": "開発者ツールを切り替える",
"toggle_full_screen": "全画面表示を切り替える",
"view": "表示"
},
"window_menu": {
"bring_all_to_front": "全てを前面に表示",
"label": "ウィンドウ",
"zoom": "ズーム"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"action": {
"cancel": "გაუქმება",
"close": "დახურვა",
"close_brand": "დახურვა %(brand)s",
"copy": "კოპირება",
"cut": "მოჭრა",
"delete": "წაშალეთ",
"edit": "რედაქტირება",
"minimise": "შეამცირეთ",
"paste": "პასტა",
"paste_match_style": "ჩასვით და მატჩის სტილი",
"quit": "თავი დაანებე",
"redo": "რედო",
"select_all": "აირჩიეთ ყველა",
"show_hide": "ჩვენება/დამალვა",
"undo": "გაუქმება",
"zoom_in": "გაზარდოთ",
"zoom_out": "გაფართოება"
},
"common": {
"about": "შესახებ",
"brand_help": "%(brand)sდახმარება",
"help": "დახმარება",
"preferences": "პრეფერენციები"
},
"confirm_quit": "დარწმუნებული ხართ, რომ გსურთ დატოვება?",
"edit_menu": {
"speech": "გამოსვლა",
"speech_start_speaking": "დაიწყეთ საუბარი",
"speech_stop_speaking": "შეწყვიტე ლაპარ"
},
"file_menu": {
"label": "ფაილი"
},
"menu": {
"hide": "დამალვა",
"hide_others": "სხვების დამალვა",
"services": "მომსახურება",
"unhide": "გამოხატე"
},
"right_click_menu": {
"add_to_dictionary": "ლექსიკონში დამატება",
"copy_email": "ელ. ფოსტის მისამართის",
"copy_image": "სურათის დაკოპირება",
"copy_image_url": "გამოსახულების მისამართის კოპირ",
"copy_link_url": "ბმულის მისამართის კოპირება",
"save_image_as": "შეინახეთ სურათი როგორც...",
"save_image_as_error_description": "სურათის შენახვა ვერ შეძლო",
"save_image_as_error_title": "სურათის შენახვა ვერ შეძლ"
},
"view_menu": {
"actual_size": "რეალური ზომა",
"toggle_developer_tools": "დეველოპერის ინსტრუმენტების",
"toggle_full_screen": "სრული ეკრანის გადართვა",
"view": "ნახვა"
},
"window_menu": {
"bring_all_to_front": "ყველაფერი წინ წამოიყვანეთ",
"label": "ფანჯარა",
"zoom": "გაზუსტება"
}
}
+91
View File
@@ -0,0 +1,91 @@
{
"action": {
"cancel": "취소",
"close": "닫기",
"close_brand": "%(brand)s 닫기",
"copy": "복사하기",
"cut": "잘라내기",
"delete": "삭제",
"edit": "편집",
"minimise": "최소화",
"paste": "붙여넣기",
"paste_match_style": "붙여넣고 스타일 일치",
"quit": "종료",
"redo": "되돌리기",
"select_all": "전체 선택",
"show_hide": "보이기/숨기기",
"undo": "실행 취소",
"zoom_in": "확대",
"zoom_out": "축소"
},
"common": {
"about": "정보",
"brand_help": "%(brand)s 도움말",
"help": "도움말",
"no": "아니오",
"preferences": "환경 설정",
"yes": "예"
},
"confirm_quit": "종료하시겠습니까?",
"edit_menu": {
"speech": "음성",
"speech_start_speaking": "말하기 시작하기",
"speech_stop_speaking": "말하기 중단하기"
},
"eol": {
"no_more_updates": "현재 지원되지 않는 macOS 버전을 사용 중입니다. %(brand)s 업데이트를 계속 받으시려면 운영 체제를 업그레이드해 주세요.",
"title": "시스템이 지원되지 않습니다",
"warning": "현재 지원되지 않는 macOS 버전을 사용 중입니다. %(brand)s을(를) 계속 사용하시려면 운영 체제를 업그레이드해야 합니다."
},
"file_menu": {
"label": "파일"
},
"icon_overlay": {
"description_error": "오류",
"description_notifications": {
"other": "읽지 않은 알림 %(count)s개가 있습니다"
}
},
"menu": {
"hide": "숨기기",
"hide_others": "다른 사람 숨기기",
"services": "서비스",
"unhide": "숨기기 취소"
},
"right_click_menu": {
"add_to_dictionary": "사전에 추가",
"copy_email": "이메일 주소 복사",
"copy_image": "이미지 복사",
"copy_image_url": "이미지 주소 복사",
"copy_link_url": "링크 주소 복사",
"save_image_as": "다음으로 이미지 저장...",
"save_image_as_error_description": "이미지 저장 실패",
"save_image_as_error_title": "이미지 저장 실패"
},
"store": {
"error": {
"backend_changed": "데이터를 지우고 다시 로드하시겠습니까?",
"backend_changed_detail": "시스템 키링에서 비밀에 접근할 수 없습니다. 변경된 것으로 보입니다.",
"backend_changed_title": "데이터베이스 로드에 실패했습니다",
"backend_no_encryption": "시스템에 지원되는 키링이 있지만 암호화를 사용할 수 없습니다.",
"backend_no_encryption_detail": "Electron이 키링 %(backend)s에서 암호화를 사용할 수 없음을 감지했습니다. 키링이 설치되어 있는지 확인하세요. 이미 설치되어 있다면, 시스템을 재부팅한 후 다시 시도해 주세요. 선택적으로 %(brand)s가 약한 형태의 암호화를 사용하도록 허용할 수 있습니다",
"backend_no_encryption_title": "암호화를 지원 안함",
"unsupported_keyring": "시스템에 지원되지 않는 키링이 존재하여 데이터베이스를 열 수 없습니다.",
"unsupported_keyring_detail": "Electron의 키링 감지 기능이 지원되는 백엔드를 찾지 못했습니다. 명령줄 인수를 사용하여 %(brand)s 를 시작함으로써 백엔드를 수동으로 구성해 볼 수 있습니다. 이는 일회성 작업입니다. 자세한 내용은 %(link)s 를 참조하십시오.",
"unsupported_keyring_title": "시스템이 지원되지 않습니다",
"unsupported_keyring_use_basic_text": "암호화 수준 낮게 사용",
"unsupported_keyring_use_plaintext": "암호화를 사용하지 마십시오"
}
},
"view_menu": {
"actual_size": "실제 크기",
"toggle_developer_tools": "개발자 도구 전환",
"toggle_full_screen": "전체 화면으로 전환",
"view": "보기"
},
"window_menu": {
"bring_all_to_front": "모두 맨 앞으로 가져오기",
"label": "창",
"zoom": "확대/축소"
}
}
+61
View File
@@ -0,0 +1,61 @@
{
"action": {
"cancel": "ຍົກເລີກ",
"close": "ປິດ",
"copy": "ສຳເນົາ",
"cut": "ຕັດ",
"delete": "ລຶບ",
"edit": "ແກ້ໄຂ",
"minimise": "ຫຍໍ້ນ້ອຍ",
"paste": "ກັອບມາໃສ່",
"paste_match_style": "ກັອບມາໃສ່ ແລະໃຫ້ສະຕາຍຕົງກັນ",
"quit": "ຍົກເລີກ",
"redo": "ລຶ້ມຄືນ",
"select_all": "ເລືອກທັງໝົດ",
"show_hide": "ສະແດງ/ເຊື່ອງ",
"undo": "ຮື້ຄືນ",
"zoom_in": "ຊູມເຂົ້າ",
"zoom_out": "ຊູມອອກ"
},
"common": {
"about": "ກ່ຽວກັບ",
"help": "ຊ່ວຍເຫຼືອ",
"preferences": "ການຕັ້ງຄ່າ"
},
"confirm_quit": "ທ່ານຕ້ອງການປິດແທ້ບໍ່?",
"edit_menu": {
"speech": "ຄຳກ່າວ",
"speech_start_speaking": "ເລີ່ມສົນທະນາ",
"speech_stop_speaking": "ເຊົາສົນທະນາ"
},
"file_menu": {
"label": "ຟາຍ"
},
"menu": {
"hide": "ເຊື່ອງ",
"hide_others": "ເຊື່ອງອັນອື່ນ",
"services": "ບໍລິການ",
"unhide": "ໂຊຄືນ"
},
"right_click_menu": {
"add_to_dictionary": "ເພີ່ມເຂົ້າໄປວັດຈະນານຸກົມ",
"copy_email": "ສຳເນົາທີ່ຢູ່ເມວ",
"copy_image": "ສຳເນົາຮູບ",
"copy_image_url": "ສຳເນົາທີ່ຢູ່ຮູບພາບ",
"copy_link_url": "ສຳເນົາທີ່ຢູ່ລິ້ງ",
"save_image_as": "ບັນທຶກຮູບພາບເປັນ...",
"save_image_as_error_description": "ຮູບພາບບໍ່ສາມາດບັດທຶກໄດ້",
"save_image_as_error_title": "ການບັນທຶກຮູບພາບບໍ່ສຳເລັດ"
},
"view_menu": {
"actual_size": "ຂະໜາດຕົວຈິງ",
"toggle_developer_tools": "ສະຫຼັບໄປໜ້າເຄື່ອງມືພັດທະນາ",
"toggle_full_screen": "ສະຫຼັບເຕັມຈໍ",
"view": "ເບິ່ງ"
},
"window_menu": {
"bring_all_to_front": "ເອົາທັງໝົດມາທາງໜ້າ",
"label": "ປ່ອງຢ້ຽມ",
"zoom": "ຊູມ"
}
}
+62
View File
@@ -0,0 +1,62 @@
{
"action": {
"cancel": "Atšaukti",
"close": "Uždaryti",
"close_brand": "Uždaryti %(brand)s",
"copy": "Kopijuoti",
"cut": "Iškirpti",
"delete": "Ištrinti",
"edit": "Koreguoti",
"minimise": "Sumažinti",
"paste": "Įklijuoti",
"paste_match_style": "Įklijuoti ir suderinti stilių",
"quit": "Išeiti",
"redo": "Sugrąžinti veiksmą",
"select_all": "Pasirinkti visus",
"show_hide": "Rodyti/Slėpti",
"undo": "Atšaukti veiksmą",
"zoom_in": "Priartinti",
"zoom_out": "Atitolinti"
},
"common": {
"about": "Apie",
"help": "Pagalba",
"preferences": "Nuostatos"
},
"confirm_quit": "Ar tikrai norite išeiti?",
"edit_menu": {
"speech": "Kalba",
"speech_start_speaking": "Pradėti kalbėti",
"speech_stop_speaking": "Nustoti kalbėti"
},
"file_menu": {
"label": "Failas"
},
"menu": {
"hide": "Slėpti",
"hide_others": "Slėpti kitus",
"services": "Paslaugos",
"unhide": "Nebeslėpti"
},
"right_click_menu": {
"add_to_dictionary": "Pridėti prie žodyno",
"copy_email": "Kopijuoti el. pašto adresą",
"copy_image": "Kopijuoti paveikslėlį",
"copy_image_url": "Kopijuoti paveikslėlio adresą",
"copy_link_url": "Kopijuoti nuorodos adresą",
"save_image_as": "Įrašyti paveikslėlį kaip...",
"save_image_as_error_description": "Paveikslėlio nepavyko išsaugoti",
"save_image_as_error_title": "Nepavyko įrašyti paveikslėlio"
},
"view_menu": {
"actual_size": "Tikrasis dydis",
"toggle_developer_tools": "Perjungti kūrėjo įrankius",
"toggle_full_screen": "Perjungti viso ekrano režimą",
"view": "Žiūrėti"
},
"window_menu": {
"bring_all_to_front": "Viską iškelti į priekį",
"label": "Langas",
"zoom": "Priartinti"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"action": {
"cancel": "Atcelt",
"close": "Aizvērt",
"close_brand": "Aizvērt %(brand)s",
"copy": "Kopēt",
"cut": "Izgriezt",
"delete": "Dzēst",
"edit": "Labot",
"minimise": "Samazināt",
"paste": "Ielīmēt",
"paste_match_style": "Ielīmēt un pielāgot stilu",
"quit": "Iziet",
"redo": "Atatsaukt",
"select_all": "Atzīmēt visu",
"show_hide": "Parādīt/paslēpt",
"undo": "Atsaukt",
"zoom_in": "Tuvināt",
"zoom_out": "Tālināt"
},
"common": {
"about": "Par",
"brand_help": "%(brand)s palīdzība",
"help": "Palīdzība",
"preferences": "Iestatījumi"
},
"confirm_quit": "Vai tiešām iziet?",
"edit_menu": {
"speech": "Runa",
"speech_start_speaking": "Uzsākt runāšanu",
"speech_stop_speaking": "Pārtraukt runāšanu"
},
"file_menu": {
"label": "Datne"
},
"menu": {
"hide": "Paslēpt",
"hide_others": "Paslēpt citus",
"services": "Pakalpojumi",
"unhide": "Rādīt"
},
"right_click_menu": {
"add_to_dictionary": "Pievienot vārdnīcai",
"copy_email": "Ievietot e-pasta adresi starpliktuvē",
"copy_image": "Ievietot attēlu starpliktuvē",
"copy_image_url": "Ievietot attēla adresi starpliktuvē",
"copy_link_url": "Ievietot saites adresi starpliktuvē",
"save_image_as": "Saglabāt attēlu kā...",
"save_image_as_error_description": "Attēlu neizdevās saglabāt",
"save_image_as_error_title": "Neizdevās saglabāt attēlu"
},
"view_menu": {
"actual_size": "Īstais izmērs",
"toggle_developer_tools": "Pārslēgt izstrādātāja rīkus",
"toggle_full_screen": "Pārslēgt pilnekrānu",
"view": "Skats"
},
"window_menu": {
"bring_all_to_front": "Iznest visu priekšplānā",
"label": "Logs",
"zoom": "Tālummaiņa"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"action": {
"cancel": "Hanafoana",
"close": "Akatona",
"close_brand": "Anakatona%(brand)s",
"copy": "Dika Mitovy",
"cut": "Tapaina",
"delete": "Fafaina",
"edit": "Anova",
"minimise": "Manamaivana",
"paste": "Koba",
"paste_match_style": "Mametaka sy Mampifanandrify ny fomba",
"quit": "Mialà",
"redo": "Averina atao",
"select_all": "Isafidy ny rehetra",
"show_hide": "Aneho/Anafina",
"undo": "Ravao",
"zoom_in": "Angedao",
"zoom_out": "Hahelezo"
},
"common": {
"about": "Mombamomba",
"brand_help": "%(marques)Fanampiana",
"help": "Fanampiana",
"preferences": "Safidy manokana"
},
"confirm_quit": "Azo Antoka ve fa tena hiala ianao",
"edit_menu": {
"speech": "Fitenenana",
"speech_start_speaking": "Atomboy ny resaka/Manomboha fitenenena",
"speech_stop_speaking": "Atsaharo ny fitenenana"
},
"file_menu": {
"label": "Manapetraka/apetrao"
},
"menu": {
"hide": "Afeno",
"hide_others": "Afeno ny hafa",
"services": "Tolotra",
"unhide": "Asehoy"
},
"right_click_menu": {
"add_to_dictionary": "Ampio ao amin'ny rakibolana",
"copy_email": "Adikao ny adiresy imailaka",
"copy_image": "Andika ny sary",
"copy_image_url": "Adikao ny adiresin'ny sary",
"copy_link_url": "Adikao ny adiresy rohy",
"save_image_as": "Hitahiry ny sary ho",
"save_image_as_error_description": "Tsy voatahiry ilay sary",
"save_image_as_error_title": "Tsy nahahomby ny fitahirizana an'ilay sary"
},
"view_menu": {
"actual_size": "Habe Ankehitriny",
"toggle_developer_tools": "Amadika fitaovana fampandrosoana",
"toggle_full_screen": "Hamadika amin'ny efijery feno",
"view": "Hijery"
},
"window_menu": {
"bring_all_to_front": "Ataovy aloha ny zava-drehetra",
"label": "Varavarankely",
"zoom": "Anakaiky fahitana"
}
}
+92
View File
@@ -0,0 +1,92 @@
{
"action": {
"cancel": "Avbryt",
"close": "Lukk",
"close_brand": "Avslutt %(brand)s",
"copy": "Kopier",
"cut": "Klipp",
"delete": "Slett",
"edit": "Rediger",
"minimise": "Minimere",
"paste": "Lim inn",
"paste_match_style": "Lim inn og match stil",
"quit": "Avslutt",
"redo": "Gjør om",
"select_all": "Velg alle",
"show_hide": "Vis/Skjul",
"undo": "Angre",
"zoom_in": "Zoom inn",
"zoom_out": "Zoom ut"
},
"common": {
"about": "Om",
"brand_help": "%(brand)s Hjelp",
"help": "Hjelp",
"no": "Nei",
"preferences": "Innstillinger",
"yes": "Ja"
},
"confirm_quit": "Er du sikker på at du vil slutte?",
"edit_menu": {
"speech": "Tale",
"speech_start_speaking": "Begynn å snakke",
"speech_stop_speaking": "Slutt å snakke"
},
"eol": {
"no_more_updates": "Du kjører en versjon av macOS som ikke støttes. Oppgrader for å motta oppdateringer fr %(brand)s.",
"title": "Systemet støttes ikke",
"warning": "Du bruker en versjon av macOS som ikke støttes. Oppgrader for å sikre at %(brand)s fortsetter å fungere."
},
"file_menu": {
"label": "Fil"
},
"icon_overlay": {
"description_error": "Feil",
"description_notifications": {
"one": "Du har %(count)s ulest varsel.",
"other": "Du har %(count)s uleste varsler."
}
},
"menu": {
"hide": "Skjul",
"hide_others": "Skjul andre",
"services": "Tjenester",
"unhide": "Slutt å skjule"
},
"right_click_menu": {
"add_to_dictionary": "Legg til i ordbok",
"copy_email": "Kopier e-postadressen",
"copy_image": "Kopier bildet",
"copy_image_url": "Kopier bildeadresse",
"copy_link_url": "Kopier link adresse",
"save_image_as": "Lagre bildet som...",
"save_image_as_error_description": "Bildet kunne ikke lagres",
"save_image_as_error_title": "Kunne ikke lagre bildet"
},
"store": {
"error": {
"backend_changed": "Tøm data og last inn på nytt?",
"backend_changed_detail": "Kan ikke få tilgang til hemmeligheten fra systemnøkkelringen, den ser ut til å ha blitt endret.",
"backend_changed_title": "Kunne ikke laste inn databasen",
"backend_no_encryption": "Systemet ditt har en støttet nøkkelring, men kryptering er ikke tilgjengelig.",
"backend_no_encryption_detail": "Electron har oppdaget at kryptering ikke er tilgjengelig på nøkkelringen %(backend)s din. Forsikre deg om at du har nøkkelringen installert. Hvis du har nøkkelringen installert, vennligst start på nytt og prøv igjen. Eventuelt kan du tillate %(brand)s å bruke en svakere form for kryptering.",
"backend_no_encryption_title": "Ingen støtte for kryptering",
"unsupported_keyring": "Systemet ditt har en nøkkelring som ikke støttes, noe som betyr at databasen ikke kan åpnes.",
"unsupported_keyring_detail": "Electrons nøkkelringdeteksjon fant ikke en støttet backend. Du kan prøve å konfigurere backend manuelt ved å starte %(brand)s med et kommandolinjeargument, en engangsoperasjon. Se%(link)s.",
"unsupported_keyring_title": "Systemet støttes ikke",
"unsupported_keyring_use_basic_text": "Bruk svakere kryptering",
"unsupported_keyring_use_plaintext": "Ikke bruk kryptering"
}
},
"view_menu": {
"actual_size": "Faktisk størrelse",
"toggle_developer_tools": "Veksle Utvikleralternativer",
"toggle_full_screen": "Veksle Fullskjerm",
"view": "Vis"
},
"window_menu": {
"bring_all_to_front": "Flytt Alt Frem",
"label": "Vindu",
"zoom": "Forstørr"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"action": {
"cancel": "Annuleren",
"close": "Sluiten",
"close_brand": "Sluit %(brand)s",
"copy": "Kopiëren",
"cut": "Knippen",
"delete": "Verwijderen",
"edit": "Bewerken",
"minimise": "Minimaliseren",
"paste": "Plakken",
"paste_match_style": "Plakken zonder stijl",
"quit": "Sluiten",
"redo": "Opnieuw doen",
"select_all": "Alles selecteren",
"show_hide": "Tonen/Verbergen",
"undo": "Ongedaan maken",
"zoom_in": "Inzoomen",
"zoom_out": "Uitzoomen"
},
"common": {
"about": "Over",
"brand_help": "%(brand)s Hulp",
"help": "Hulp",
"preferences": "Voorkeuren"
},
"confirm_quit": "Weet u zeker dat u wilt stoppen?",
"edit_menu": {
"speech": "Spraak",
"speech_start_speaking": "Begin met praten",
"speech_stop_speaking": "Stop met praten"
},
"file_menu": {
"label": "Bestand"
},
"menu": {
"hide": "Verbergen",
"hide_others": "Anderen verbergen",
"services": "Diensten",
"unhide": "Weer laten zien"
},
"right_click_menu": {
"add_to_dictionary": "Aan woordenboek toevoegen",
"copy_email": "E-mailadres kopiëren",
"copy_image": "Afbeelding kopiëren",
"copy_image_url": "Kopieer afbeeldingsadres",
"copy_link_url": "Link kopiëren",
"save_image_as": "Afbeelding opslaan als...",
"save_image_as_error_description": "De afbeelding opslaan is mislukt",
"save_image_as_error_title": "Afbeelding opslaan is mislukt"
},
"view_menu": {
"actual_size": "Werkelijke grootte",
"toggle_developer_tools": "Developer Tools wisselen",
"toggle_full_screen": "Volledig scherm wisselen",
"view": "Bekijken"
},
"window_menu": {
"bring_all_to_front": "Alles naar voren brengen",
"label": "Venster",
"zoom": "Zoom"
}
}
+93
View File
@@ -0,0 +1,93 @@
{
"action": {
"cancel": "Anuluj",
"close": "Zamknij",
"close_brand": "Zamknij %(brand)s",
"copy": "Kopiuj",
"cut": "Wytnij",
"delete": "Usuń",
"edit": "Edytuj",
"minimise": "Minimalizuj",
"paste": "Wklej",
"paste_match_style": "Wklej i dopasuj styl",
"quit": "Zamknij",
"redo": "Ponów",
"select_all": "Zaznacz wszystko",
"show_hide": "Pokaż/Ukryj",
"undo": "Cofnij",
"zoom_in": "Powiększ",
"zoom_out": "Pomniejsz"
},
"common": {
"about": "Informacje",
"brand_help": "Pomoc %(brand)s",
"help": "Pomoc",
"no": "Nie",
"preferences": "Preferencje",
"yes": "Tak"
},
"confirm_quit": "Czy na pewno chcesz zamknąć?",
"edit_menu": {
"speech": "Mowa",
"speech_start_speaking": "Zacznij mówić",
"speech_stop_speaking": "Przestań mówić"
},
"eol": {
"no_more_updates": "Korzystasz z nieobsługiwanej wersji systemu macOS. Zaktualizuj system, aby uzyskać aktualizacje %(brand)s.",
"title": "System nie jest obsługiwany",
"warning": "Korzystasz z nieobsługiwanej wersji systemu macOS. Zaktualizuj system, aby dalej korzystać z %(brand)s."
},
"file_menu": {
"label": "Plik"
},
"icon_overlay": {
"description_error": "Błąd",
"description_notifications": {
"one": "Masz %(count)s nieprzeczytane powiadomienie.",
"few": "Masz %(count)s nieprzeczytane powiadomienia.",
"many": "Masz %(count)s nieprzeczytanych powiadomień."
}
},
"menu": {
"hide": "Ukryj",
"hide_others": "Ukryj inne",
"services": "Usługi",
"unhide": "Odkryj"
},
"right_click_menu": {
"add_to_dictionary": "Dodaj do słownika",
"copy_email": "Kopiuj adres e-mail",
"copy_image": "Kopiuj obraz",
"copy_image_url": "Kopiuj adres obrazu",
"copy_link_url": "Kopiuj adres odnośnika",
"save_image_as": "Zapisz obraz jako...",
"save_image_as_error_description": "Obraz nie został zapisany",
"save_image_as_error_title": "Nie udało się zapisać obrazu"
},
"store": {
"error": {
"backend_changed": "Wyczyścić dane i przeładować?",
"backend_changed_detail": "Nie można uzyskać dostępu do sekretnego magazynu, wygląda na to, że uległ zmianie.",
"backend_changed_title": "Nie udało się załadować bazy danych",
"backend_no_encryption": "Twój system posiada wspierany keyring, ale szyfrowanie nie jest dostępne.",
"backend_no_encryption_detail": "Elektron wykrył, że szyfrowanie nie jest dostępne w twoim keyring'u %(backend)s. Upewnij się, że keyring został zainstalowany. Jeśli tak, uruchom ponownie urządzenie i spróbuj ponownie. Opcjonalnie, zezwól %(brand)s, aby korzystał ze słabszego szyfrowania.",
"backend_no_encryption_title": "Szyfrowanie nie jest obsługiwane",
"unsupported_keyring": "System zawiera niewspierany keyring, nie można otworzyć bazy danych.",
"unsupported_keyring_detail": "Wykrywanie breloków firmy Electron nie znalazło obsługiwanego zaplecza. Możesz spróbować ręcznie skonfigurować zaplecze, zaczynając od argumentu %(brand)s wiersza polecenia, operacji jednorazowej. Widzieć%(link)s.",
"unsupported_keyring_title": "System niewspierany",
"unsupported_keyring_use_basic_text": "Użyj słabszego szyfrowania",
"unsupported_keyring_use_plaintext": "Nie używaj szyfrowania"
}
},
"view_menu": {
"actual_size": "Rozmiar rzeczywisty",
"toggle_developer_tools": "Przełącz narzędzia deweloperskie",
"toggle_full_screen": "Przełącz pełny ekran",
"view": "Wyświetl"
},
"window_menu": {
"bring_all_to_front": "Wyciągnij wszystko do przodu",
"label": "Okno",
"zoom": "Powiększenie"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"action": {
"cancel": "Cancelar",
"close": "Fechar",
"close_brand": "Fecha %(brand)s",
"copy": "Copiar",
"cut": "Cortar",
"delete": "Apagar",
"edit": "Editar",
"minimise": "Minimizar",
"paste": "Colar",
"paste_match_style": "Colar e combinar o estilo",
"quit": "Desistir",
"redo": "Refazer",
"select_all": "Selecionar tudo",
"show_hide": "Mostrar/ocultar",
"undo": "Desfazer",
"zoom_in": "Ampliar",
"zoom_out": "Reduzir"
},
"common": {
"about": "Sobre",
"brand_help": "%(brand)s Ajuda",
"help": "Ajuda",
"preferences": "Preferências"
},
"confirm_quit": "Tens a certeza de que queres desistir?",
"edit_menu": {
"speech": "Discurso",
"speech_start_speaking": "Começa a falar",
"speech_stop_speaking": "Pára de falar"
},
"file_menu": {
"label": "Ficheiro"
},
"menu": {
"hide": "Ocultar",
"hide_others": "Ocultar Outros",
"services": "Serviços",
"unhide": "Mostrar"
},
"right_click_menu": {
"add_to_dictionary": "Adicionar ao dicionário",
"copy_email": "Copiar endereço de e-mail",
"copy_image": "Copiar imagem",
"copy_image_url": "Copiar endereço da imagem",
"copy_link_url": "Copiar endereço do link",
"save_image_as": "Salvar imagem como...",
"save_image_as_error_description": "A imagem não foi salva",
"save_image_as_error_title": "Falha ao salvar a imagem"
},
"view_menu": {
"actual_size": "Tamanho original",
"toggle_developer_tools": "Alternar ferramentas de desenvolvedor",
"toggle_full_screen": "Alternar ecrã inteiro",
"view": "Ver"
},
"window_menu": {
"bring_all_to_front": "Traz tudo para a frente",
"label": "Janela",
"zoom": "Ampliação"
}
}
+92
View File
@@ -0,0 +1,92 @@
{
"action": {
"cancel": "Cancelar",
"close": "Fechar",
"close_brand": "Fechar %(brand)s",
"copy": "Copiar",
"cut": "Cortar",
"delete": "Excluir",
"edit": "Editar",
"minimise": "Minimizar",
"paste": "Colar",
"paste_match_style": "Colar e Adequar Estilo",
"quit": "Sair",
"redo": "Refazer",
"select_all": "Selecionar Todas",
"show_hide": "Mostrar/Esconder",
"undo": "Desfazer",
"zoom_in": "Dar Zoom In",
"zoom_out": "Dar Zoom Out"
},
"common": {
"about": "Sobre",
"brand_help": "%(brand)s Ajuda",
"help": "Ajuda",
"no": "Não",
"preferences": "Preferências",
"yes": "Sim"
},
"confirm_quit": "Você tem certeza que você quer sair?",
"edit_menu": {
"speech": "Fala",
"speech_start_speaking": "Começar a Falar",
"speech_stop_speaking": "Parar de Falar"
},
"eol": {
"no_more_updates": "Você está usando uma versão não suportada do macOS. Atualize para receber as atualizações d %(brand)s.",
"title": "Sistema não suportado",
"warning": "Você está usando uma versão não compatível do macOS. Faça a atualização para garantir que o %(brand)s continue funcionando."
},
"file_menu": {
"label": "Arquivo"
},
"icon_overlay": {
"description_error": "Erro",
"description_notifications": {
"one": "Você tem %(count)s notificação não lida.",
"other": "Você tem %(count)s notificações não lidas."
}
},
"menu": {
"hide": "Esconder",
"hide_others": "Esconder Outras(os)",
"services": "Serviços",
"unhide": "Desesconder"
},
"right_click_menu": {
"add_to_dictionary": "Adicionar a dicionário",
"copy_email": "Copiar endereço de email",
"copy_image": "Copiar imagem",
"copy_image_url": "Copiar endereço de imagem",
"copy_link_url": "Copiar endereço de link",
"save_image_as": "Salvar imagem como...",
"save_image_as_error_description": "A imagem falhou para salvar",
"save_image_as_error_title": "Falha para salvar imagem"
},
"store": {
"error": {
"backend_changed": "Limpar dados e recarregar?",
"backend_changed_detail": "Não foi possível acessar o segredo no cofre do sistema, parece que ele foi alterado.",
"backend_changed_title": "Falha ao carregar o banco de dados",
"backend_no_encryption": "Seu sistema tem um cofre compatível, mas a criptografia não está disponível.",
"backend_no_encryption_detail": "O Electron detetou que a encriptação não está disponível no seu cofre %(backend)s. Certifique-se de que tem o cofre instalado. Se tiver o cofre instalado, reinicie e tente novamente. Opcionalmente, você pode permitir que %(brand)s use uma forma mais fraca de criptografia.",
"backend_no_encryption_title": "Sem suporte para criptografia",
"unsupported_keyring": "Seu sistema possui um cofre não compatível, o que impede a abertura do banco de dados.",
"unsupported_keyring_detail": "A detecção de cofre do Electron não encontrou um backend compatível. Você pode tentar configurar manualmente o backend iniciando %(brand)s com um argumento de linha de comando, uma operação única. Consulte %(link)s.",
"unsupported_keyring_title": "Sistema não suportado",
"unsupported_keyring_use_basic_text": "Use criptografia mais fraca",
"unsupported_keyring_use_plaintext": "Não usar criptografia"
}
},
"view_menu": {
"actual_size": "Tamanho de Verdade",
"toggle_developer_tools": "Ativar/Desativar Ferramentas de Desenvolvimento",
"toggle_full_screen": "Pôr em/Tirar de Tela Cheia",
"view": "Ver"
},
"window_menu": {
"bring_all_to_front": "Trazer Todas Para Frente",
"label": "Janela",
"zoom": "Zoom"
}
}
+93
View File
@@ -0,0 +1,93 @@
{
"action": {
"cancel": "Отмена",
"close": "Закрыть",
"close_brand": "Закрыть %(brand)s",
"copy": "Копировать",
"cut": "Вырезать",
"delete": "Удалить",
"edit": "Изменить",
"minimise": "Свернуть",
"paste": "Вставить",
"paste_match_style": "Вставить с тем же стилем",
"quit": "Выйти",
"redo": "Повторить",
"select_all": "Выбрать все",
"show_hide": "Показать/скрыть",
"undo": "Отменить",
"zoom_in": "Увеличить",
"zoom_out": "Уменьшить"
},
"common": {
"about": "О программе",
"brand_help": "Помощь %(brand)s",
"help": "Помощь",
"no": "Нет",
"preferences": "Предпочтения",
"yes": "Да"
},
"confirm_quit": "Вы уверены, что хотите выйти?",
"edit_menu": {
"speech": "Речь",
"speech_start_speaking": "Говорите",
"speech_stop_speaking": "Перестаньте говорить"
},
"eol": {
"no_more_updates": "Вы используете неподдерживаемую версию macOS. Пожалуйста обновите систему чтобы получать обновления %(brand)s.",
"title": "Система не поддерживается",
"warning": "Вы используете неподдерживаемую версию macOS. Пожалуйста обновите её, чтобы %(brand)s продолжал работать."
},
"file_menu": {
"label": "Файл"
},
"icon_overlay": {
"description_error": "Ошибка",
"description_notifications": {
"one": "У вас есть %(count)s непрочитанное уведомление.",
"few": "У вас есть %(count)s непрочитанных уведомления.",
"many": "У вас есть %(count)s непрочитанных уведомлений."
}
},
"menu": {
"hide": "Скрыть",
"hide_others": "Скрыть прочие",
"services": "Службы",
"unhide": "Показать"
},
"right_click_menu": {
"add_to_dictionary": "Добавить в словарь",
"copy_email": "Копировать адрес почты",
"copy_image": "Копировать изображение",
"copy_image_url": "Копировать адрес изображения",
"copy_link_url": "Копировать ссылку",
"save_image_as": "Сохранить изображение как...",
"save_image_as_error_description": "Не удалось сохранить изображение",
"save_image_as_error_title": "Не удалось сохранить изображение"
},
"store": {
"error": {
"backend_changed": "Очистить данные и перезагрузить?",
"backend_changed_detail": "Не удалось получить доступ к секрету из системной связки ключей. Похоже, что она изменилась.",
"backend_changed_title": "Не удалось загрузить базу данных",
"backend_no_encryption": "В вашей системе имеется поддерживаемая связка ключей, но шифрование недоступно.",
"backend_no_encryption_detail": "Electron обнаружил, что шифрование недоступно в вашем хранилище %(backend)s Убедитесь, что у вас установлено связное устройство. Если связное устройство установлено, перезагрузите компьютер и повторите попытку. При желании вы можете разрешить %(brand)s использовать более слабую форму шифрования.",
"backend_no_encryption_title": "Нет поддержки шифрования",
"unsupported_keyring": "Невозможно открыть базу данных, так как в вашей системе установлена неподдерживаемая связка ключей",
"unsupported_keyring_detail": "Функция обнаружения ключей Electron не нашла поддерживаемый сервер. Можно попробовать настроить сервер вручную, запустив %(brand)s с аргументом командной строки, это нужно сделать только один раз. Смотри %(link)s.",
"unsupported_keyring_title": "Система не поддерживается",
"unsupported_keyring_use_basic_text": "Использовать более слабое шифрование",
"unsupported_keyring_use_plaintext": "Не использовать шифрование"
}
},
"view_menu": {
"actual_size": "Фактический размер",
"toggle_developer_tools": "Переключить инструменты разработчика",
"toggle_full_screen": "Переключить полноэкранный режим",
"view": "Просмотр"
},
"window_menu": {
"bring_all_to_front": "Вынести всё вперёд",
"label": "Окно",
"zoom": "Масштаб"
}
}
+93
View File
@@ -0,0 +1,93 @@
{
"action": {
"cancel": "Zrušiť",
"close": "Zavrieť",
"close_brand": "Zatvoriť %(brand)s",
"copy": "Kopírovať",
"cut": "Vystrihnúť",
"delete": "Odstrániť",
"edit": "Upraviť",
"minimise": "Minimalizovať",
"paste": "Vložiť",
"paste_match_style": "Vložiť a prispôsobiť štýl",
"quit": "Ukončiť",
"redo": "Opakovať",
"select_all": "Vybrať všetko",
"show_hide": "Zobraziť/Skryť",
"undo": "Späť",
"zoom_in": "Priblížiť",
"zoom_out": "Oddialiť"
},
"common": {
"about": "Informácie",
"brand_help": "%(brand)s Pomoc",
"help": "Pomocník",
"no": "Nie",
"preferences": "Predvoľby",
"yes": "Áno"
},
"confirm_quit": "Naozaj chcete zavrieť aplikáciu?",
"edit_menu": {
"speech": "Reč",
"speech_start_speaking": "Spustiť nahrávanie hlasu",
"speech_stop_speaking": "Zastaviť nahrávanie hlasu"
},
"eol": {
"no_more_updates": "Používate nepodporovanú verziu systému macOS. Prosím, aktualizujte systém, aby ste mohli dostávať aktualizácie aplikácie %(brand)s.",
"title": "Systém nie je podporovaný",
"warning": "Používate nepodporovanú verziu systému macOS. Vykonajte prosím aktualizáciu, aby aplikácia %(brand)s mohla správne fungovať."
},
"file_menu": {
"label": "Súbor"
},
"icon_overlay": {
"description_error": "Chyba",
"description_notifications": {
"one": "Máte %(count)s neprečítané oznámenie.",
"few": "Máte %(count)s neprečítané oznámenia.",
"other": "Máte %(count)s neprečítaných oznámení."
}
},
"menu": {
"hide": "Skryť",
"hide_others": "Skryť ostatné",
"services": "Služby",
"unhide": "Odkryť"
},
"right_click_menu": {
"add_to_dictionary": "Pridať do slovníka",
"copy_email": "Kopírovať e-mailovú adresu",
"copy_image": "Kopírovať obrázok",
"copy_image_url": "Kopírovať adresu obrázka",
"copy_link_url": "Kopírovať adresu odkazu",
"save_image_as": "Uložiť obrázok ako...",
"save_image_as_error_description": "Obrázok sa nepodarilo uložiť",
"save_image_as_error_title": "Chyba pri ukladaní obrázka"
},
"store": {
"error": {
"backend_changed": "Vymazať údaje a znova načítať?",
"backend_changed_detail": "Nepodarilo sa získať prístup k tajnému kľúču zo systémového zväzku kľúčov, zdá sa, že sa zmenil.",
"backend_changed_title": "Nepodarilo sa načítať databázu",
"backend_no_encryption": "Váš systém má podporovaný zväzok kľúčov, ale šifrovanie nie je k dispozícii.",
"backend_no_encryption_detail": "Electron zistil, že šifrovanie nie je k dispozícii na vašom zväzku kľúčov %(backend)s. Uistite sa, že máte nainštalovaný zväzok kľúčov. Ak máte zväzok kľúčov nainštalovaný, reštartujte počítač a skúste to znova. Voliteľne môžete povoliť aplikácii %(brand)s používať slabšiu formu šifrovania.",
"backend_no_encryption_title": "Žiadna podpora šifrovania",
"unsupported_keyring": "Váš systém má nepodporovaný zväzok kľúčov, čo znamená, že databázu nemožno otvoriť.",
"unsupported_keyring_detail": "Detekcia zväzku kľúčov aplikácie Electron nenašla podporovaný backend. Môžete sa pokúsiť manuálne nastaviť backend spustením aplikácie %(brand)s s argumentom príkazového riadka, je to jednorazová operácia. Pozrite si %(link)s .",
"unsupported_keyring_title": "Systém nie je podporovaný",
"unsupported_keyring_use_basic_text": "Použiť slabšie šifrovanie",
"unsupported_keyring_use_plaintext": "Nepoužiť žiadne šifrovanie"
}
},
"view_menu": {
"actual_size": "Aktuálna veľkosť",
"toggle_developer_tools": "Nástroje pre vývojárov",
"toggle_full_screen": "Celá obrazovka",
"view": "Zobraziť"
},
"window_menu": {
"bring_all_to_front": "Preniesť všetky do popredia",
"label": "Okno",
"zoom": "Lupa"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"action": {
"cancel": "Anuloje",
"close": "Mbylle",
"copy": "Kopjoje",
"delete": "Fshije",
"edit": "Përpuno"
},
"common": {
"about": "Mbi",
"help": "Ndihmë",
"preferences": "Parapëlqime"
},
"view_menu": {
"view": "Shihni"
}
}
+87
View File
@@ -0,0 +1,87 @@
{
"action": {
"cancel": "Avbryt",
"close": "Stäng",
"close_brand": "Stäng %(brand)s",
"copy": "Kopiera",
"cut": "Klipp ut",
"delete": "Radera",
"edit": "Ändra",
"minimise": "Minimera",
"paste": "Klistra in",
"paste_match_style": "Klistra in och matcha stilen",
"quit": "Avsluta",
"redo": "Gör om",
"select_all": "Markera allt",
"show_hide": "Visa/dölj",
"undo": "Ångra",
"zoom_in": "Zooma in",
"zoom_out": "Zooma ut"
},
"common": {
"about": "Om",
"brand_help": "%(brand)s-hjälp",
"help": "Hjälp",
"no": "Nej",
"preferences": "Inställningar",
"yes": "Ja"
},
"confirm_quit": "Är du säker att du vill avsluta?",
"edit_menu": {
"speech": "Tal",
"speech_start_speaking": "Börja tala",
"speech_stop_speaking": "Sluta tala"
},
"file_menu": {
"label": "Arkiv"
},
"icon_overlay": {
"description_error": "Fel",
"description_notifications": {
"one": "Du har %(count)s oläst avisering.",
"other": "Du har %(count)s olästa aviseringar."
}
},
"menu": {
"hide": "Göm",
"hide_others": "Göm övriga",
"services": "Tjänster",
"unhide": "Sluta gömma"
},
"right_click_menu": {
"add_to_dictionary": "Lägg till i ordlistan",
"copy_email": "Kopiera e-postadress",
"copy_image": "Kopiera bild",
"copy_image_url": "Kopiera bildadress",
"copy_link_url": "Kopiera länkadress",
"save_image_as": "Spara bild som…",
"save_image_as_error_description": "Bilden sparades inte",
"save_image_as_error_title": "Misslyckades med att spara bilden"
},
"store": {
"error": {
"backend_changed": "Rensa data och ladda om?",
"backend_changed_detail": "Kunde inte komma åt hemligheten från systemnyckelringen, det verkar ha ändrats.",
"backend_changed_title": "Misslyckades att ladda databasen",
"backend_no_encryption": "Ditt system har en nyckelring som stöds men kryptering är inte tillgänglig.",
"backend_no_encryption_detail": "Electron har upptäckt att kryptering inte är tillgänglig på din nyckelring %(backend)s. Se till att du har nyckelringen installerad. Om du har nyckelringen installerad, starta om och försök igen. Alternativt kan du tillåta %(brand)s att använda en svagare form av kryptering.",
"backend_no_encryption_title": "Inget krypteringsstöd",
"unsupported_keyring": "Ditt system har en nyckelring som inte stöds, vilket innebär att databasen inte kan öppnas.",
"unsupported_keyring_detail": "Electrons nyckelringsdetektering hittade inte en backend som stöds. Du kan försöka konfigurera backend manuellt genom att starta %(brand)s med ett kommandoradsargument, en engångsåtgärd. Se %(link)s.",
"unsupported_keyring_title": "Systemet stöds inte",
"unsupported_keyring_use_basic_text": "Använd svagare kryptering",
"unsupported_keyring_use_plaintext": "Använd ingen kryptering"
}
},
"view_menu": {
"actual_size": "Verklig storlek",
"toggle_developer_tools": "Växla utvecklarverktyg",
"toggle_full_screen": "Växla helskärm",
"view": "Visa"
},
"window_menu": {
"bring_all_to_front": "Lägg alla överst",
"label": "Fönster",
"zoom": "Zooma"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"action": {
"cancel": "İptal",
"close": "Kapat",
"close_brand": "Kapat %(brand)s",
"copy": "Kopyala",
"cut": "Kes",
"delete": "Sil",
"edit": "Düzenle",
"minimise": "Küçült",
"paste": "Yapıştır",
"paste_match_style": "Stili Yapıştır ve Eşleştir",
"quit": "Çık",
"redo": "Yeniden yap",
"select_all": "Tümünü seç",
"show_hide": "Göster/Gizle",
"undo": "Geri al",
"zoom_in": "Yakınlaştır",
"zoom_out": "Uzaklaştır"
},
"common": {
"about": "Hakkında",
"brand_help": "%(brand)s Yardım",
"help": "Yardım",
"preferences": "Tercihler"
},
"confirm_quit": "Çıkmak istediğinizden emin misiniz?",
"edit_menu": {
"speech": "Konuşma",
"speech_start_speaking": "Konuşmaya başla",
"speech_stop_speaking": "Konuşmayı durdur"
},
"file_menu": {
"label": "Dosya"
},
"menu": {
"hide": "Gizle",
"hide_others": "Diğerlerini gizle",
"services": "Hizmetler",
"unhide": "Göster"
},
"right_click_menu": {
"add_to_dictionary": "Sözlüğe ekle",
"copy_email": "E-posta adresini kopyala",
"copy_image": "Resmi kopyala",
"copy_image_url": "Görsel adresini kopyala",
"copy_link_url": "Bağlantılı adresi kopyala",
"save_image_as": "Resmi farklı kaydet...",
"save_image_as_error_description": "Görüntü kaydedilemedi",
"save_image_as_error_title": "Resim kaydedilemedi"
},
"view_menu": {
"actual_size": "Gerçek boyut",
"toggle_developer_tools": "Geliştirici araçları",
"toggle_full_screen": "Tam ekran",
"view": "Görüntüle"
},
"window_menu": {
"bring_all_to_front": "Hepsini öne getir",
"label": "Pencere",
"zoom": "Yaklaştır"
}
}
+93
View File
@@ -0,0 +1,93 @@
{
"action": {
"cancel": "Скасувати",
"close": "Закрити",
"close_brand": "Закрити %(brand)s",
"copy": "Скопіювати",
"cut": "Вирізати",
"delete": "Видалити",
"edit": "Змінити",
"minimise": "Згорнути",
"paste": "Вставити",
"paste_match_style": "Вставити з таким же стилем",
"quit": "Вийти",
"redo": "Повторити дію",
"select_all": "Вибрати все",
"show_hide": "Показати/Сховати",
"undo": "Скасувати дію",
"zoom_in": "Збільшити",
"zoom_out": "Зменшити"
},
"common": {
"about": "Про застосунок",
"brand_help": "Довідка %(brand)s",
"help": "Довідка",
"no": "Ні",
"preferences": "Параметри",
"yes": "Так"
},
"confirm_quit": "Ви впевнені, що хочете вийти?",
"edit_menu": {
"speech": "Мовлення",
"speech_start_speaking": "Почати говорити",
"speech_stop_speaking": "Припинити говорити"
},
"eol": {
"no_more_updates": "Ви використовуєте непідтримувану версію macOS. Оновіть її, щоб отримати оновлення %(brand)s.",
"title": "Система не підтримується",
"warning": "Ви використовуєте непідтримувану версію macOS. Оновіть систему, щоб забезпечити безперебійну роботу %(brand)s."
},
"file_menu": {
"label": "Файл"
},
"icon_overlay": {
"description_error": "Помилка",
"description_notifications": {
"one": "У вас є %(count)s непрочитане сповіщення.",
"few": "У вас є %(count)s непрочитані сповіщення.",
"many": "У вас є %(count)s непрочитаних сповіщень."
}
},
"menu": {
"hide": "Сховати",
"hide_others": "Сховати інші",
"services": "Служби",
"unhide": "Показати"
},
"right_click_menu": {
"add_to_dictionary": "Додати до словника",
"copy_email": "Копіювати адресу е-пошти",
"copy_image": "Копіювати зображення",
"copy_image_url": "Копіювати адресу зображення",
"copy_link_url": "Копіювати адресу посилання",
"save_image_as": "Зберегти зображення як...",
"save_image_as_error_description": "Не вдалося зберегти зображення",
"save_image_as_error_title": "Не вдалося зберегти зображення"
},
"store": {
"error": {
"backend_changed": "Очистити дані та перезавантажити?",
"backend_changed_detail": "Не вдається отримати доступ до таємного ключа з системного набору ключів, видається, він змінився.",
"backend_changed_title": "Не вдалося завантажити базу даних",
"backend_no_encryption": "Ваша система підтримує сховище ключів, але шифрування недоступне.",
"backend_no_encryption_detail": "Electron виявив, що шифрування недоступне у вашому сховищі ключів %(backend)s. Переконайтеся, що у вас встановлено сховище ключів. Якщо так, тоді перезавантажте комп'ютер і повторіть спробу. За бажанням, ви можете дозволити %(brand)s використовувати слабшу форму шифрування.",
"backend_no_encryption_title": "Шифрування не підтримується",
"unsupported_keyring": "Ваша система має непідтримуваний набір ключів. Це означає, що базу даних неможливо відкрити.",
"unsupported_keyring_detail": "Electron не виявив підтримуваного бекенда для роботи зі сховищем паролів. Ви можете вручну налаштувати його, запустивши %(brand)s з відповідним аргументом у командному рядку. Цю дію потрібно виконати лише один раз. Докладніше – %(link)s.",
"unsupported_keyring_title": "Система не підтримується",
"unsupported_keyring_use_basic_text": "Використовувати слабше шифрування",
"unsupported_keyring_use_plaintext": "Не використовувати шифрування"
}
},
"view_menu": {
"actual_size": "Фактичний розмір",
"toggle_developer_tools": "Перемкнути інструменти розробника",
"toggle_full_screen": "Перемкнути повноекранний режим",
"view": "Перегляд"
},
"window_menu": {
"bring_all_to_front": "Винести все вперед",
"label": "Вікно",
"zoom": "Масштаб"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"action": {
"cancel": "Huỷ bỏ",
"close": "Đóng",
"close_brand": "Đóng %(brand)s",
"copy": "Sao chép",
"cut": "Cắt",
"delete": "Xoá",
"edit": "Sửa",
"minimise": "Thu nhỏ",
"paste": "Dán",
"paste_match_style": "Dán và khớp kiểu",
"quit": "Thoát",
"redo": "Làm lại",
"select_all": "Chọn tất cả",
"show_hide": "Hiện/Ẩn",
"undo": "Hoàn tác",
"zoom_in": "Phóng to",
"zoom_out": "Thu nhỏ"
},
"common": {
"about": "Giới thiệu",
"brand_help": "Hỗ trợ %(brand)s",
"help": "Hỗ trợ",
"preferences": "Tùy chọn"
},
"confirm_quit": "Bạn có chắc chắn muốn thoát?",
"edit_menu": {
"speech": "Đọc màn hình",
"speech_start_speaking": "Bắt đầu nói",
"speech_stop_speaking": "Dừng nói"
},
"file_menu": {
"label": "Tệp"
},
"menu": {
"hide": "Ẩn",
"hide_others": "Ẩn cái khác",
"services": "Dịch vụ",
"unhide": "Bỏ ẩn"
},
"right_click_menu": {
"add_to_dictionary": "Thêm vào từ điển",
"copy_email": "Sao chép địa chỉ email",
"copy_image": "Sao chép ảnh",
"copy_image_url": "Sao chép địa chỉ ảnh",
"copy_link_url": "Sao chép địa chỉ liên kết",
"save_image_as": "Lưu ảnh…",
"save_image_as_error_description": "Ảnh không lưu được",
"save_image_as_error_title": "Không lưu được ảnh"
},
"view_menu": {
"actual_size": "Kích thước thực",
"toggle_developer_tools": "Công cụ phát triển",
"toggle_full_screen": "Toàn màn hình",
"view": "Xem"
},
"window_menu": {
"bring_all_to_front": "Đưa tất cả lên trước",
"label": "Cửa sổ",
"zoom": "Thu phóng"
}
}
@@ -0,0 +1,91 @@
{
"action": {
"cancel": "取消",
"close": "关闭",
"close_brand": "关闭 %(brand)s",
"copy": "复制",
"cut": "剪切",
"delete": "删除",
"edit": "编辑",
"minimise": "最小化",
"paste": "粘贴",
"paste_match_style": "粘贴并匹配样式",
"quit": "退出",
"redo": "重做",
"select_all": "选中全部",
"show_hide": "显示/隐藏",
"undo": "撤销",
"zoom_in": "放大",
"zoom_out": "缩小"
},
"common": {
"about": "关于",
"brand_help": "%(brand)s帮助",
"help": "帮助",
"no": "否",
"preferences": "偏好",
"yes": "是"
},
"confirm_quit": "你确定要退出吗?",
"edit_menu": {
"speech": "讲话",
"speech_start_speaking": "开始讲话",
"speech_stop_speaking": "停止讲话"
},
"eol": {
"no_more_updates": "你正在使用的 macOS 版本不受支持。请升级以获取 %(brand)s 更新。",
"title": "不受支持的系统",
"warning": "你正在使用的 macOS 版本不受支持。请升级系统以确保 %(brand)s 能保持运行。"
},
"file_menu": {
"label": "文件"
},
"icon_overlay": {
"description_error": "错误",
"description_notifications": {
"other": "你有 %(count)s 条未读通知。"
}
},
"menu": {
"hide": "隐藏",
"hide_others": "隐藏其他",
"services": "服务",
"unhide": "显示"
},
"right_click_menu": {
"add_to_dictionary": "添加到字典",
"copy_email": "复制邮箱地址",
"copy_image": "复制图片",
"copy_image_url": "复制图片地址",
"copy_link_url": "复制链接地址",
"save_image_as": "保存图片为……",
"save_image_as_error_description": "图片保存失败",
"save_image_as_error_title": "图片保存失败"
},
"store": {
"error": {
"backend_changed": "清除数据并重新加载?",
"backend_changed_detail": "无法从系统密钥环访问密钥,该密钥似乎已被更改。",
"backend_changed_title": "数据库加载失败",
"backend_no_encryption": "你的系统支持密钥环,但加密功能不可用。",
"backend_no_encryption_detail": "Electron 检测到你的密钥环 %(backend)s 的加密不可用。请确保已安装该密钥环。若已安装请重启设备后重试。也可选择允许 %(brand)s 使用弱加密方式。",
"backend_no_encryption_title": "不支持加密",
"unsupported_keyring": "你的系统存在不受支持的密钥环,这意味着无法打开数据库。",
"unsupported_keyring_detail": "Electron 的密钥环检测未找到受支持的后端。你可以尝试通过命令行参数启动 %(brand)s 来手动配置后端,此操作仅需执行一次。详情请参阅:%(link)s。",
"unsupported_keyring_title": "不受支持的系统",
"unsupported_keyring_use_basic_text": "使用弱加密",
"unsupported_keyring_use_plaintext": "不使用加密"
}
},
"view_menu": {
"actual_size": "实际大小",
"toggle_developer_tools": "切换开发者工具",
"toggle_full_screen": "切换全屏",
"view": "查看"
},
"window_menu": {
"bring_all_to_front": "全部置前",
"label": "窗口",
"zoom": "放大"
}
}
@@ -0,0 +1,63 @@
{
"action": {
"cancel": "取消",
"close": "關閉",
"close_brand": "關閉 %(brand)s",
"copy": "複製",
"cut": "剪下",
"delete": "刪除",
"edit": "編輯",
"minimise": "最小化",
"paste": "貼上",
"paste_match_style": "貼上並保留格式",
"quit": "離開",
"redo": "取消復原",
"select_all": "全選",
"show_hide": "顯示/隱藏",
"undo": "復原",
"zoom_in": "放大",
"zoom_out": "縮小"
},
"common": {
"about": "關於",
"brand_help": "%(brand)s 說明",
"help": "說明",
"preferences": "偏好設定"
},
"confirm_quit": "您確定要離開嗎?",
"edit_menu": {
"speech": "語音",
"speech_start_speaking": "開始說話",
"speech_stop_speaking": "停止說話"
},
"file_menu": {
"label": "檔案"
},
"menu": {
"hide": "隱藏",
"hide_others": "隱藏其他",
"services": "服務",
"unhide": "取消隱藏"
},
"right_click_menu": {
"add_to_dictionary": "新增到字典",
"copy_email": "複製電子郵件地址",
"copy_image": "複製圖片",
"copy_image_url": "複製圖片地址",
"copy_link_url": "複製連結",
"save_image_as": "另存圖片為...",
"save_image_as_error_description": "儲存圖片失敗",
"save_image_as_error_title": "儲存圖片失敗"
},
"view_menu": {
"actual_size": "實際大小",
"toggle_developer_tools": "切換開發工具",
"toggle_full_screen": "切換全螢幕",
"view": "檢視"
},
"window_menu": {
"bring_all_to_front": "全部移至最前",
"label": "視窗",
"zoom": "縮放"
}
}
+17
View File
@@ -0,0 +1,17 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import path from "node:path";
import { getAsarPath } from "./asar.js";
export async function getIconPath(): Promise<string> {
const asarPath = await getAsarPath();
const iconFile = `icon.${process.platform === "win32" ? "ico" : "png"}`;
return path.join(path.dirname(asarPath), "build", iconFile);
}
+227
View File
@@ -0,0 +1,227 @@
/*
Copyright 2022-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { app, autoUpdater, desktopCapturer, ipcMain, powerSaveBlocker, TouchBar, nativeImage } from "electron";
import IpcMainEvent = Electron.IpcMainEvent;
import { randomArray } from "./utils.js";
import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback.js";
import Store, { clearDataAndRelaunch } from "./store.js";
let focusHandlerAttached = false;
ipcMain.on("loudNotification", function (): void {
if (process.platform === "win32" || process.platform === "linux") {
if (global.mainWindow && !global.mainWindow.isFocused() && !focusHandlerAttached) {
global.mainWindow.flashFrame(true);
global.mainWindow.once("focus", () => {
global.mainWindow?.flashFrame(false);
focusHandlerAttached = false;
});
focusHandlerAttached = true;
}
}
});
let powerSaveBlockerId: number | null = null;
ipcMain.on("app_onAction", function (_ev: IpcMainEvent, payload) {
switch (payload.action) {
case "call_state": {
if (powerSaveBlockerId !== null && powerSaveBlocker.isStarted(powerSaveBlockerId)) {
if (payload.state === "ended") {
powerSaveBlocker.stop(powerSaveBlockerId);
powerSaveBlockerId = null;
}
} else {
if (powerSaveBlockerId === null && payload.state === "connected") {
powerSaveBlockerId = powerSaveBlocker.start("prevent-display-sleep");
}
}
break;
}
}
});
ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
const store = Store.instance;
if (!global.mainWindow || !store) return;
const args = payload.args || [];
let ret: any;
switch (payload.name) {
case "getUpdateFeedUrl":
ret = autoUpdater.getFeedURL();
break;
case "setLanguage":
global.appLocalization.setAppLocale(args[0]);
break;
case "getAppVersion":
ret = app.getVersion();
break;
case "focusWindow":
if (global.mainWindow.isMinimized()) {
global.mainWindow.restore();
} else {
global.mainWindow.show();
global.mainWindow.focus();
}
break;
case "navigateBack":
if (global.mainWindow.webContents.canGoBack()) {
global.mainWindow.webContents.goBack();
}
break;
case "navigateForward":
if (global.mainWindow.webContents.canGoForward()) {
global.mainWindow.webContents.goForward();
}
break;
case "setSpellCheckEnabled":
if (typeof args[0] !== "boolean") return;
global.mainWindow.webContents.session.setSpellCheckerEnabled(args[0]);
store.set("spellCheckerEnabled", args[0]);
break;
case "getSpellCheckEnabled":
ret = store.get("spellCheckerEnabled");
break;
case "setSpellCheckLanguages":
try {
global.mainWindow.webContents.session.setSpellCheckerLanguages(args[0]);
} catch (er) {
console.log("There were problems setting the spellcheck languages", er);
}
break;
case "getSpellCheckLanguages":
ret = global.mainWindow.webContents.session.getSpellCheckerLanguages();
break;
case "getAvailableSpellCheckLanguages":
ret = global.mainWindow.webContents.session.availableSpellCheckerLanguages;
break;
case "getPickleKey":
try {
ret = await store.getSecret(`${args[0]}|${args[1]}`);
} catch {
// if an error is thrown (e.g. we can't initialise safeStorage),
// then return null, which means the default pickle key will be used
ret = null;
}
break;
case "createPickleKey":
try {
const pickleKey = await randomArray(32);
await store.setSecret(`${args[0]}|${args[1]}`, pickleKey);
ret = pickleKey;
} catch (e) {
console.error("Failed to create pickle key", e);
ret = null;
}
break;
case "destroyPickleKey":
try {
await store.deleteSecret(`${args[0]}|${args[1]}`);
} catch (e) {
console.error("Failed to destroy pickle key", e);
}
break;
case "getDesktopCapturerSources":
ret = (await desktopCapturer.getSources(args[0])).map((source) => ({
id: source.id,
name: source.name,
thumbnailURL: source.thumbnail.toDataURL(),
}));
break;
case "callDisplayMediaCallback":
await getDisplayMediaCallback()?.({ video: args[0] });
setDisplayMediaCallback(null);
ret = null;
break;
case "clearStorage":
await clearDataAndRelaunch(global.mainWindow.webContents.session);
return; // the app is about to stop, we don't need to reply to the IPC
case "breadcrumbs": {
if (process.platform === "darwin") {
const { TouchBarPopover, TouchBarButton } = TouchBar;
const recentsBar = new TouchBar({
items: args[0].map((r: { roomId: string; avatarUrl: string | null; initial: string }) => {
const defaultColors = ["#0DBD8B", "#368bd6", "#ac3ba8"];
let total = 0;
for (let i = 0; i < r.roomId.length; ++i) {
total += r.roomId.charCodeAt(i);
}
const button = new TouchBarButton({
label: r.initial,
backgroundColor: defaultColors[total % defaultColors.length],
click: (): void => {
void global.mainWindow?.loadURL(`vector://vector/webapp/#/room/${r.roomId}`);
},
});
if (r.avatarUrl) {
void fetch(r.avatarUrl)
.then((resp) => {
if (!resp.ok) return;
return resp.arrayBuffer();
})
.then((arrayBuffer) => {
if (!arrayBuffer) return;
const buffer = Buffer.from(arrayBuffer);
button.icon = nativeImage.createFromBuffer(buffer);
button.label = "";
button.backgroundColor = "";
});
}
return button;
}),
});
const touchBar = new TouchBar({
items: [
new TouchBarPopover({
label: "Recents",
showCloseButton: true,
items: recentsBar,
}),
],
});
global.mainWindow.setTouchBar(touchBar);
}
break;
}
default:
global.mainWindow.webContents.send("ipcReply", {
id: payload.id,
error: "Unknown IPC Call: " + payload.name,
});
return;
}
global.mainWindow?.webContents.send("ipcReply", {
id: payload.id,
reply: ret,
});
});
ipcMain.handle("getConfig", () => global.vectorConfig);
const initialisePromiseWithResolvers = Promise.withResolvers<void>();
export const initialisePromise = initialisePromiseWithResolvers.promise;
ipcMain.once("initialise", () => {
initialisePromiseWithResolvers.resolve();
});
+138
View File
@@ -0,0 +1,138 @@
/*
Copyright 2021-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import counterpart from "counterpart";
import { type TranslationKey as TKey } from "matrix-web-i18n";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import type EN from "./i18n/strings/en_EN.json";
import { loadJsonFile } from "./utils.js";
import type Store from "./store.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const FALLBACK_LOCALE = "en";
type TranslationKey = TKey<typeof EN>;
type SubstitutionValue = number | string;
interface Variables {
[key: string]: SubstitutionValue | undefined;
count?: number;
}
export function _t(text: TranslationKey, variables: Variables = {}): string {
const { count } = variables;
// Horrible hack to avoid https://github.com/vector-im/element-web/issues/4191
// The interpolation library that counterpart uses does not support undefined/null
// values and instead will throw an error. This is a problem since everywhere else
// in JS land passing undefined/null will simply stringify instead, and when converting
// valid ES6 template strings to i18n strings it's extremely easy to pass undefined/null
// if there are no existing null guards. To avoid this making the app completely inoperable,
// we'll check all the values for undefined/null and stringify them here.
Object.keys(variables).forEach((key) => {
if (variables[key] === undefined) {
console.warn("safeCounterpartTranslate called with undefined interpolation name: " + key);
variables[key] = "undefined";
}
if (variables[key] === null) {
console.warn("safeCounterpartTranslate called with null interpolation name: " + key);
variables[key] = "null";
}
});
let translated = counterpart.translate(text, variables);
if (!translated && count !== undefined) {
// counterpart does not do fallback if no pluralisation exists in the preferred language, so do it here
translated = counterpart.translate(text, { ...variables, locale: FALLBACK_LOCALE });
}
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
return translated;
}
type Component = () => void;
export class AppLocalization {
private static readonly STORE_KEY = "locale";
private readonly localizedComponents?: Set<Component>;
private readonly store: Store;
public constructor({ components = [], store }: { components: Component[]; store: Store }) {
counterpart.registerTranslations(FALLBACK_LOCALE, this.fetchTranslationJson("en_EN"));
counterpart.setFallbackLocale(FALLBACK_LOCALE);
counterpart.setSeparator("|");
this.store = store;
if (Array.isArray(components)) {
this.localizedComponents = new Set(components);
}
if (store.has(AppLocalization.STORE_KEY)) {
const locales = store.get(AppLocalization.STORE_KEY);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.setAppLocale(locales!);
}
this.resetLocalizedUI();
}
// Format language strings from normalized form to non-normalized form (e.g. en-gb to en_GB)
private denormalize(locale: string): string {
if (locale === "en") {
locale = "en_EN";
}
const parts = locale.split("-");
if (parts.length > 1) {
parts[1] = parts[1].toUpperCase();
}
return parts.join("_");
}
public fetchTranslationJson(locale: string): Record<string, string> {
try {
console.log("Fetching translation json for locale: " + locale);
return loadJsonFile(__dirname, "i18n", "strings", `${this.denormalize(locale)}.json`);
} catch (e) {
console.log(`Could not fetch translation json for locale: '${locale}'`, e);
return {};
}
}
public setAppLocale(locales: string | string[]): void {
console.log(`Changing application language to ${locales}`);
if (!Array.isArray(locales)) {
locales = [locales];
}
const loadedLocales = locales.filter((locale) => {
const translations = this.fetchTranslationJson(locale);
if (translations !== null) {
counterpart.registerTranslations(locale, translations);
}
return !!translations;
});
counterpart.setLocale(loadedLocales[0]);
this.store.set(AppLocalization.STORE_KEY, locales);
this.resetLocalizedUI();
}
public resetLocalizedUI(): void {
console.log("Resetting the UI components after locale change");
this.localizedComponents?.forEach((componentSetup) => {
if (typeof componentSetup === "function") {
componentSetup();
}
});
}
}
+159
View File
@@ -0,0 +1,159 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { BrowserWindow } from "electron";
export function setupMacosTitleBar(window: BrowserWindow): void {
if (process.platform !== "darwin") return;
let cssKey: string | undefined;
async function applyStyling(): Promise<void> {
cssKey = await window.webContents.insertCSS(`
/* Create margin of space for the traffic light buttons */
.mx_UserMenu {
/* We zero the margin and use padding as we want to use it as a drag handle */
margin-top: 0 !important;
margin-left: 0 !important;
padding-top: 32px !important;
padding-left: 20px !important;
-webkit-app-region: drag;
-webkit-user-select: none;
}
/* Exclude the button from being a drag handle and not working */
.mx_UserMenu > * {
-webkit-app-region: no-drag;
}
/* Maintain alignment of the toggle space panel button */
.mx_SpacePanel_toggleCollapse {
/* 19px original top value, 32px margin-top above, 12px original margin-top value */
top: calc(19px + 32px - 12px) !important;
}
/* Prevent the media lightbox sender info from clipping into the traffic light buttons */
.mx_ImageView_info_wrapper {
margin-top: 32px;
}
/* Mark the splash screen as a drag handle */
.mx_MatrixChat_splash {
-webkit-app-region: drag;
}
/* Exclude the splash buttons from being drag handles */
.mx_MatrixChat_splashButtons {
-webkit-app-region: no-drag;
}
/* Mark the background as a drag handle */
.mx_AuthPage {
-webkit-app-region: drag;
}
/* Exclude the main content elements from being drag handles */
.mx_AuthPage .mx_AuthPage_modalContent,
.mx_AuthPage .mx_AuthPage_modalBlur,
.mx_AuthPage .mx_AuthFooter > *,
.mx_AuthPage .mx_Dropdown_menu {
-webkit-app-region: no-drag;
}
/* Mark the home page background as a drag handle */
.mx_HomePage {
-webkit-app-region: drag;
}
/* Exclude interactive elements from being drag handles */
.mx_HomePage .mx_HomePage_body,
.mx_HomePage .mx_HomePage_default_wrapper > * {
-webkit-app-region: no-drag;
}
/* Mark the header as a drag handle */
.mx_ImageView_panel {
-webkit-app-region: drag;
}
/* Exclude header interactive elements from being drag handles */
.mx_ImageView_panel > .mx_ImageView_info_wrapper,
.mx_ImageView_panel > .mx_ImageView_title,
.mx_ImageView_panel > .mx_ImageView_toolbar > * {
-webkit-app-region: no-drag;
}
/* Mark the background as a drag handle only if no modal is open */
.mx_MatrixChat_wrapper[aria-hidden="false"] .mx_RoomView_wrapper,
.mx_MatrixChat_wrapper[aria-hidden="false"] .mx_HomePage {
-webkit-app-region: drag;
}
/* Exclude content elements from being drag handles */
.mx_SpaceRoomView_landing > *,
.mx_RoomPreviewBar,
.mx_RoomView_body,
.mx_AutoHideScrollbar,
.mx_RightPanel_ResizeWrapper,
.mx_RoomPreviewCard,
.mx_LeftPanel,
.mx_RoomView,
.mx_SpaceRoomView,
.mx_AccessibleButton,
.mx_Dialog {
-webkit-app-region: no-drag;
}
/* Exclude context menus and their backgrounds */
.mx_ContextualMenu, .mx_ContextualMenu_background {
-webkit-app-region: no-drag;
}
/* Exclude iframes, such as recaptcha */
iframe {
-webkit-app-region: no-drag;
}
/* Add a bar above room header + left panel */
.mx_LeftPanel {
flex-direction: column;
}
.mx_LeftPanel::before {
content: "";
height: 20px;
-webkit-app-region: drag;
}
.mx_LeftPanel_newRoomList::before {
/* Aligned with the room header */
height: 13px;
border-right: 1px solid var(--cpd-color-bg-subtle-primary);
}
.mx_RoomView::before,
.mx_SpaceRoomView::before {
content: "";
-webkit-app-region: drag;
}
.mx_SpaceRoomView::before {
display: block;
height: 24px;
}
.mx_RoomView::before {
height: 13px;
}
`);
}
window.on("enter-full-screen", () => {
if (cssKey !== undefined) {
void window.webContents.removeInsertedCSS(cssKey);
}
});
window.on("leave-full-screen", () => {
void applyStyling();
});
window.webContents.on("did-finish-load", () => {
if (!window.isFullScreen()) {
void applyStyling();
}
});
}
+106
View File
@@ -0,0 +1,106 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type BrowserWindow, ipcMain, session } from "electron";
/**
* Check for feature support from the server.
* This requires asking the renderer process for supported versions.
*/
async function getSupportedVersions(window: BrowserWindow): Promise<string[]> {
return new Promise((resolve) => {
ipcMain.once("serverSupportedVersions", (_, versionsResponse) => {
resolve(versionsResponse?.versions || []);
});
window.webContents.send("serverSupportedVersions"); // ping now that the listener exists
});
}
/**
* Get the access token for the user.
* This requires asking the renderer process for the access token.
*/
async function getAccessToken(window: BrowserWindow): Promise<string | undefined> {
return new Promise((resolve) => {
ipcMain.once("userAccessToken", (_, accessToken) => {
resolve(accessToken);
});
window.webContents.send("userAccessToken"); // ping now that the listener exists
});
}
/**
* Get the homeserver url
* This requires asking the renderer process for the homeserver url.
*/
async function getHomeserverUrl(window: BrowserWindow): Promise<string> {
return new Promise((resolve) => {
ipcMain.once("homeserverUrl", (_, homeserver) => {
resolve(homeserver);
});
window.webContents.send("homeserverUrl"); // ping now that the listener exists
});
}
export function setupMediaAuth(window: BrowserWindow): void {
session.defaultSession.webRequest.onBeforeRequest(async (req, callback) => {
// This handler emulates the element-web service worker, where URLs are rewritten late in the request
// for backwards compatibility. As authenticated media becomes more prevalent, this should be replaced
// by the app using authenticated URLs from the outset.
try {
const url = new URL(req.url);
if (
!url.pathname.startsWith("/_matrix/media/v3/download") &&
!url.pathname.startsWith("/_matrix/media/v3/thumbnail")
) {
return callback({}); // not a URL we care about
}
const supportedVersions = await getSupportedVersions(window);
// We have to check that the access token is truthy otherwise we'd be intercepting pre-login media request too,
// e.g. those required for SSO button icons.
const accessToken = await getAccessToken(window);
if (supportedVersions.includes("v1.11") && accessToken) {
url.href = url.href.replace(/\/media\/v3\/(.*)\//, "/client/v1/media/$1/");
return callback({ redirectURL: url.toString() });
} else {
return callback({}); // no support == no modification
}
} catch (e) {
console.error(e);
}
});
session.defaultSession.webRequest.onBeforeSendHeaders(async (req, callback) => {
try {
const url = new URL(req.url);
if (!url.pathname.startsWith("/_matrix/client/v1/media")) {
return callback({}); // invoke unmodified
}
// Is this request actually going to the homeserver?
// We don't combine this check with the one above on purpose.
// We're fetching the homeserver url through IPC and should do so
// as sparingly as possible.
const homeserver = await getHomeserverUrl(window);
const isRequestToHomeServer = homeserver && url.origin === new URL(homeserver).origin;
if (!isRequestToHomeServer) {
return callback({}); // invoke unmodified
}
// Only add authorization header to authenticated media URLs. This emulates the service worker
// behaviour in element-web.
const accessToken = await getAccessToken(window);
// `accessToken` can be falsy, but if we're trying to download media without authentication
// then we should expect failure anyway.
const headers = { ...req.requestHeaders, Authorization: `Bearer ${accessToken}` };
return callback({ requestHeaders: headers });
} catch (e) {
console.error(e);
}
});
}
+79
View File
@@ -0,0 +1,79 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018, 2019 , 2021 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// This file is compiled to CommonJS rather than ESM otherwise the browser chokes on the import statement.
import { ipcRenderer, contextBridge, IpcRendererEvent } from "electron";
// Expose only expected IPC wrapper APIs to the renderer process to avoid
// handing out generalised messaging access.
const CHANNELS = [
"app_onAction",
"before-quit",
"check_updates",
"install_update",
"ipcCall",
"ipcReply",
"loudNotification",
"preferences",
"seshat",
"seshatReply",
"setBadgeCount",
"update-downloaded",
"userDownloadCompleted",
"userDownloadAction",
"openDesktopCapturerSourcePicker",
"userAccessToken",
"homeserverUrl",
"serverSupportedVersions",
"showToast",
];
contextBridge.exposeInMainWorld("electron", {
on(channel: string, listener: (event: IpcRendererEvent, ...args: any[]) => void): void {
if (!CHANNELS.includes(channel)) {
console.error(`Unknown IPC channel ${channel} ignored`);
return;
}
ipcRenderer.on(channel, listener);
},
send(channel: string, ...args: any[]): void {
if (!CHANNELS.includes(channel)) {
console.error(`Unknown IPC channel ${channel} ignored`);
return;
}
ipcRenderer.send(channel, ...args);
},
async initialise(): Promise<{
protocol: string;
sessionId: string;
config: IConfigOptions;
supportedSettings: Record<string, boolean>;
/**
* Do we need to render badge overlays for new notifications?
*/
supportsBadgeOverlay: boolean;
}> {
ipcRenderer.emit("initialise");
const [{ protocol, sessionId }, config, supportedSettings] = await Promise.all([
ipcRenderer.invoke("getProtocol"),
ipcRenderer.invoke("getConfig"),
ipcRenderer.invoke("getSupportedSettings"),
]);
return { protocol, sessionId, config, supportedSettings, supportsBadgeOverlay: process.platform === "win32" };
},
async setSettingValue(settingName: string, value: any): Promise<void> {
return ipcRenderer.invoke("setSettingValue", settingName, value);
},
async getSettingValue(settingName: string): Promise<any> {
return ipcRenderer.invoke("getSettingValue", settingName);
},
});
+87
View File
@@ -0,0 +1,87 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { expect, describe, it, beforeEach, vi } from "vitest";
import { fs as memfs, vol } from "memfs";
import ProtocolHandler from "./protocol.js";
const TEST_PROTOCOL = "test.proto";
const TEST_SESSION_ID = "test_session_id";
const USER_DATA_DIR = "/Users/name/Library/Application Support/Element";
vi.mock("node:fs", () => ({ default: memfs }));
vi.mock("electron", () => ({
app: {
getPath: vi.fn().mockReturnValue("/Users/name/Library/Application Support/Element"),
on: vi.fn(),
},
ipcMain: {
handle: vi.fn(),
},
}));
beforeEach(() => {
// Reset the state of the in-memory fs
vol.reset();
});
describe("ProtocolHandler", () => {
describe("getProfileFromDeeplink", () => {
const handler = new ProtocolHandler(TEST_PROTOCOL);
beforeEach(() => {
vol.fromJSON(
{
"./sso-sessions.json": JSON.stringify({ [TEST_SESSION_ID]: USER_DATA_DIR }),
},
USER_DATA_DIR,
);
});
it("should handle legacy SSO URIs", () => {
expect(
handler.getProfileFromDeeplink([
"Element.app",
`element://vector/webapp/?element-desktop-ssoid=${TEST_SESSION_ID}`,
]),
).toBe(USER_DATA_DIR);
});
it("should handle OIDC URIs with response_mode=query", () => {
expect(
handler.getProfileFromDeeplink([
"Element.app",
`${TEST_PROTOCOL}:/vector/webapp/?no_universal_links=true&code=DEADBEEF&state=foobar:element-desktop-ssoid:${TEST_SESSION_ID}`,
]),
).toBe(USER_DATA_DIR);
});
it("should handle OIDC URIs with response_mode=fragment", () => {
expect(
handler.getProfileFromDeeplink([
"Element.app",
`${TEST_PROTOCOL}:/vector/webapp/?no_universal_links=true#code=DEADBEEF&state=foobar:element-desktop-ssoid:${TEST_SESSION_ID}`,
]),
).toBe(USER_DATA_DIR);
});
it("should handle malformed OIDC URIs gracefully", () => {
expect(
handler.getProfileFromDeeplink([
"Element.app",
`${TEST_PROTOCOL}:/vector/webapp/?no_universal_links=true#code=DEADBEEF:element-desktop-ssoid:${TEST_SESSION_ID}`,
]),
).toBeUndefined();
});
it("should handle unrelated URIs gracefully", () => {
expect(handler.getProfileFromDeeplink(["Element.app", `${TEST_PROTOCOL}:/vector/webapp/`])).toBeUndefined();
expect(handler.getProfileFromDeeplink(["Element.app", `test.unrelated:/vector/webapp/`])).toBeUndefined();
});
});
});
+158
View File
@@ -0,0 +1,158 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { app, ipcMain } from "electron";
import { URL } from "node:url";
import path from "node:path";
import fs from "node:fs";
import { randomUUID } from "node:crypto";
const LEGACY_PROTOCOL = "element";
const SEARCH_PARAM = "element-desktop-ssoid";
const STORE_FILE_NAME = "sso-sessions.json";
// we getPath userData before electron-main changes it, so this is the default value
const storePath = path.join(app.getPath("userData"), STORE_FILE_NAME);
export default class ProtocolHandler {
private readonly store: Record<string, string> = {};
private readonly sessionId: string;
public constructor(private readonly protocol: string) {
// get all args except `hidden` as it'd mean the app would not get focused
// XXX: passing args to protocol handlers only works on Windows, so unpackaged deep-linking
// --profile/--profile-dir are passed via the SEARCH_PARAM var in the callback url
const args = process.argv.slice(1).filter((arg) => arg !== "--hidden" && arg !== "-hidden");
if (app.isPackaged) {
app.setAsDefaultProtocolClient(this.protocol, process.execPath, args);
app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, args);
} else if (process.platform === "win32") {
// on Mac/Linux this would just cause the electron binary to open
// special handler for running without being packaged, e.g `electron .` by passing our app path to electron
app.setAsDefaultProtocolClient(this.protocol, process.execPath, [app.getAppPath(), ...args]);
app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, [app.getAppPath(), ...args]);
}
if (process.platform === "darwin") {
// Protocol handler for macos
app.on("open-url", (ev, url) => {
ev.preventDefault();
this.processUrl(url);
});
} else {
// Protocol handler for win32/Linux
app.on("second-instance", (ev, commandLine) => {
const url = commandLine[commandLine.length - 1];
if (!url.startsWith(`${this.protocol}:/`) && !url.startsWith(`${LEGACY_PROTOCOL}://`)) return;
this.processUrl(url);
});
}
this.store = this.readStore();
this.sessionId = randomUUID();
ipcMain.handle("getProtocol", this.onGetProtocol);
}
private readonly onGetProtocol = (): { protocol: string; sessionId: string } => {
return {
protocol: this.protocol,
sessionId: this.sessionId,
};
};
private processUrl(url: string): void {
if (!global.mainWindow) return;
const parsed = new URL(url);
// sanity check: we only register for the one protocol, so we shouldn't
// be getting anything else unless the user is forcing a URL to open
// with the Element app.
if (parsed.protocol !== `${this.protocol}:` && parsed.protocol !== `${LEGACY_PROTOCOL}:`) {
console.log("Ignoring unexpected protocol: ", parsed.protocol);
return;
}
const urlToLoad = new URL("vector://vector/webapp/");
// ignore anything other than the search (used for SSO login redirect)
// and the hash (for general element deep links)
// There's no reason to allow anything else, particularly other paths,
// since this would allow things like the internal jitsi wrapper to
// be loaded, which would get the app stuck on that page and generally
// be a bit strange and confusing.
urlToLoad.search = parsed.search;
urlToLoad.hash = parsed.hash;
console.log("Opening URL: ", urlToLoad.href);
void global.mainWindow.loadURL(urlToLoad.href);
}
private readStore(): Record<string, string> {
try {
const s = fs.readFileSync(storePath, { encoding: "utf8" });
const o = JSON.parse(s);
return typeof o === "object" ? o : {};
} catch (e) {
console.warn("Unable to read protocol store, starting with empty store: ", e);
return {};
}
}
private writeStore(): void {
fs.writeFileSync(storePath, JSON.stringify(this.store));
}
public initialise(userDataPath: string): void {
for (const key in this.store) {
// ensure each instance only has one (the latest) session ID to prevent the file growing unbounded
if (this.store[key] === userDataPath) {
delete this.store[key];
break;
}
}
this.store[this.sessionId] = userDataPath;
this.writeStore();
}
public getProfileFromDeeplink(args: string[]): string | undefined {
// check if we are passed a profile in the SSO callback url
const deeplinkUrl = args.find(
(arg) => arg.startsWith(`${this.protocol}:/`) || arg.startsWith(`${LEGACY_PROTOCOL}://`),
);
if (deeplinkUrl?.includes(SEARCH_PARAM)) {
const parsedUrl = new URL(deeplinkUrl);
if (parsedUrl.protocol === `${this.protocol}:` || parsedUrl.protocol === `${LEGACY_PROTOCOL}:`) {
const store = this.readStore();
let sessionId = parsedUrl.searchParams.get(SEARCH_PARAM);
if (!sessionId) {
// In OIDC, we must shuttle the value in the `state` param rather than `element-desktop-ssoid`
// We encode it as a suffix like `:element-desktop-ssoid:XXYYZZ`.
// The OIDC flow may have used response_mode=fragment or query, so we need to handle both cases.
let searchParams = parsedUrl.searchParams;
if (parsedUrl.hash.includes("=")) {
const [params] = parsedUrl.hash.substring(1).split("?", 2);
searchParams = new URLSearchParams(params);
}
const state = searchParams.get("state");
if (state) {
sessionId = state.split(`:${SEARCH_PARAM}:`)[1];
}
}
if (!sessionId) {
console.warn("Unable to read session ID in deeplink url:", deeplinkUrl);
return undefined;
}
console.log("Forwarding to profile:", store[sessionId]);
return store[sessionId];
}
}
}
}
+315
View File
@@ -0,0 +1,315 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { app, ipcMain } from "electron";
import { promises as afs } from "node:fs";
import path from "node:path";
import type {
Seshat as SeshatType,
SeshatRecovery as SeshatRecoveryType,
ReindexError as ReindexErrorType,
} from "matrix-seshat"; // Hak dependency type
import IpcMainEvent = Electron.IpcMainEvent;
import { randomArray } from "./utils.js";
import Store from "./store.js";
let seshatSupported = false;
let Seshat: typeof SeshatType;
let SeshatRecovery: typeof SeshatRecoveryType;
let ReindexError: typeof ReindexErrorType;
try {
const seshatModule = await import("matrix-seshat");
Seshat = seshatModule.Seshat;
SeshatRecovery = seshatModule.SeshatRecovery;
ReindexError = seshatModule.ReindexError;
seshatSupported = true;
} catch (e) {
if ((<NodeJS.ErrnoException>e).code === "MODULE_NOT_FOUND") {
console.log("Seshat isn't installed, event indexing is disabled.");
} else {
console.warn("Seshat unexpected error:", e);
}
}
let eventIndex: SeshatType | null = null;
const seshatDefaultPassphrase = "DEFAULT_PASSPHRASE";
async function getOrCreatePassphrase(store: Store, key: string): Promise<string> {
try {
const storedPassphrase = await store.getSecret(key);
if (storedPassphrase !== undefined) {
return storedPassphrase;
}
} catch (e) {
console.error("Error getting the event index passphrase out of the secret store", e);
}
try {
const newPassphrase = await randomArray(32);
await store.setSecret(key, newPassphrase);
return newPassphrase;
} catch (e) {
console.error("Error creating new event index passphrase, using default", e);
}
return seshatDefaultPassphrase;
}
const deleteContents = async (p: string): Promise<void> => {
try {
for (const entry of await afs.readdir(p)) {
const curPath = path.join(p, entry);
try {
await afs.unlink(curPath);
} catch (e) {
console.log("Error deleting a file in EventStore directory", e);
}
}
} catch (e) {
console.log("Error reading the files in EventStore directory", e);
}
};
ipcMain.on("seshat", async function (_ev: IpcMainEvent, payload): Promise<void> {
const store = Store.instance;
if (!global.mainWindow || !store) return;
// We do this here to ensure we get the path after --profile has been resolved
const eventStorePath = path.join(app.getPath("userData"), "EventStore");
const sendError = (id: string, e: Error): void => {
const error = {
message: e.message,
};
global.mainWindow?.webContents.send("seshatReply", { id, error });
};
const args = payload.args || [];
let ret: any;
switch (payload.name) {
case "supportsEventIndexing":
ret = seshatSupported;
break;
case "initEventIndex":
if (eventIndex === null) {
const userId = args[0];
const deviceId = args[1];
const passphraseKey = `seshat|${userId}|${deviceId}`;
const passphrase = await getOrCreatePassphrase(store, passphraseKey);
try {
await afs.mkdir(eventStorePath, { recursive: true });
eventIndex = new Seshat(eventStorePath, { passphrase });
} catch (e) {
if (e instanceof ReindexError) {
// If this is a reindex error, the index schema
// changed. Try to open the database in recovery mode,
// reindex the database and finally try to open the
// database again.
const recoveryIndex = new SeshatRecovery(eventStorePath, {
passphrase,
});
const userVersion = await recoveryIndex.getUserVersion();
// If our user version is 0 we'll delete the db
// anyways so reindexing it is a waste of time.
if (userVersion === 0) {
await recoveryIndex.shutdown();
await deleteContents(eventStorePath);
} else {
await recoveryIndex.reindex();
}
eventIndex = new Seshat(eventStorePath, { passphrase });
} else {
sendError(payload.id, <Error>e);
return;
}
}
}
break;
case "closeEventIndex":
if (eventIndex !== null) {
const index = eventIndex;
eventIndex = null;
try {
await index.shutdown();
} catch (e) {
sendError(payload.id, <Error>e);
return;
}
}
break;
case "deleteEventIndex": {
await deleteContents(eventStorePath);
break;
}
case "isEventIndexEmpty":
if (eventIndex === null) ret = true;
else ret = await eventIndex.isEmpty();
break;
case "isRoomIndexed":
if (eventIndex === null) ret = false;
else ret = await eventIndex.isRoomIndexed(args[0]);
break;
case "addEventToIndex":
try {
eventIndex?.addEvent(args[0], args[1]);
} catch (e) {
sendError(payload.id, <Error>e);
return;
}
break;
case "deleteEvent":
try {
ret = await eventIndex?.deleteEvent(args[0]);
} catch (e) {
sendError(payload.id, <Error>e);
return;
}
break;
case "commitLiveEvents":
try {
ret = await eventIndex?.commit();
} catch (e) {
sendError(payload.id, <Error>e);
return;
}
break;
case "searchEventIndex":
try {
ret = await eventIndex?.search(args[0]);
} catch (e) {
sendError(payload.id, <Error>e);
return;
}
break;
case "addHistoricEvents":
if (eventIndex === null) ret = false;
else {
try {
ret = await eventIndex.addHistoricEvents(args[0], args[1], args[2]);
} catch (e) {
sendError(payload.id, <Error>e);
return;
}
}
break;
case "getStats":
if (eventIndex === null) ret = 0;
else {
try {
ret = await eventIndex.getStats();
} catch (e) {
sendError(payload.id, <Error>e);
return;
}
}
break;
case "removeCrawlerCheckpoint":
if (eventIndex === null) ret = false;
else {
try {
ret = await eventIndex.removeCrawlerCheckpoint(args[0]);
} catch (e) {
sendError(payload.id, <Error>e);
return;
}
}
break;
case "addCrawlerCheckpoint":
if (eventIndex === null) ret = false;
else {
try {
ret = await eventIndex.addCrawlerCheckpoint(args[0]);
} catch (e) {
sendError(payload.id, <Error>e);
return;
}
}
break;
case "loadFileEvents":
if (eventIndex === null) ret = [];
else {
try {
ret = await eventIndex.loadFileEvents(args[0]);
} catch (e) {
sendError(payload.id, <Error>e);
return;
}
}
break;
case "loadCheckpoints":
if (eventIndex === null) ret = [];
else {
try {
ret = await eventIndex.loadCheckpoints();
} catch {
ret = [];
}
}
break;
case "setUserVersion":
if (eventIndex === null) break;
else {
try {
await eventIndex.setUserVersion(args[0]);
} catch (e) {
sendError(payload.id, <Error>e);
return;
}
}
break;
case "getUserVersion":
if (eventIndex === null) ret = 0;
else {
try {
ret = await eventIndex.getUserVersion();
} catch (e) {
sendError(payload.id, <Error>e);
return;
}
}
break;
default:
global.mainWindow?.webContents.send("seshatReply", {
id: payload.id,
error: "Unknown IPC Call: " + payload.name,
});
return;
}
global.mainWindow?.webContents.send("seshatReply", {
id: payload.id,
reply: ret,
});
});
+116
View File
@@ -0,0 +1,116 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { ipcMain } from "electron";
import * as tray from "./tray.js";
import Store from "./store.js";
import { AutoLaunch, type AutoLaunchState } from "./auto-launch.js";
interface Setting {
read(): Promise<any>;
write(value: any): Promise<void>;
supported?(): boolean; // if undefined, the setting is always supported
}
const Settings: Record<string, Setting> = {
"Electron.autoLaunch": {
async read(): Promise<AutoLaunchState> {
return AutoLaunch.instance.getState();
},
async write(value: AutoLaunchState): Promise<void> {
return AutoLaunch.instance.setState(value);
},
},
"Electron.warnBeforeExit": {
async read(): Promise<any> {
return Store.instance?.get("warnBeforeExit");
},
async write(value: any): Promise<void> {
Store.instance?.set("warnBeforeExit", value);
},
},
"Electron.alwaysShowMenuBar": {
// This isn't relevant on Mac as Menu bars don't live in the app window
supported(): boolean {
return process.platform !== "darwin";
},
async read(): Promise<any> {
return !global.mainWindow!.autoHideMenuBar;
},
async write(value: any): Promise<void> {
Store.instance?.set("autoHideMenuBar", !value);
global.mainWindow!.autoHideMenuBar = !value;
global.mainWindow!.setMenuBarVisibility(value);
},
},
"Electron.showTrayIcon": {
// Things other than Mac support tray icons
supported(): boolean {
return process.platform !== "darwin";
},
async read(): Promise<any> {
return tray.hasTray();
},
async write(value: any): Promise<void> {
if (value) {
// Create trayIcon icon
await tray.create();
} else {
tray.destroy();
}
Store.instance?.set("minimizeToTray", value);
},
},
"Electron.enableHardwareAcceleration": {
async read(): Promise<any> {
return !Store.instance?.get("disableHardwareAcceleration");
},
async write(value: any): Promise<void> {
Store.instance?.set("disableHardwareAcceleration", !value);
},
},
"Electron.enableContentProtection": {
// Unsupported on Linux https://www.electronjs.org/docs/latest/api/browser-window#winsetcontentprotectionenable-macos-windows
// Broken on macOS https://github.com/electron/electron/issues/19880
supported(): boolean {
return process.platform === "win32";
},
async read(): Promise<any> {
return Store.instance?.get("enableContentProtection");
},
async write(value: any): Promise<void> {
global.mainWindow?.setContentProtection(value);
Store.instance?.set("enableContentProtection", value);
},
},
};
ipcMain.handle("getSupportedSettings", async () => {
const supportedSettings: Record<string, boolean> = {};
for (const [key, setting] of Object.entries(Settings)) {
supportedSettings[key] = setting.supported?.() ?? true;
}
return supportedSettings;
});
ipcMain.handle("setSettingValue", async (_ev, settingName: string, value: any) => {
const setting = Settings[settingName];
if (!setting) {
throw new Error(`Unknown setting: ${settingName}`);
}
console.debug(`Writing setting value for: ${settingName} = ${value}`);
await setting.write(value);
});
ipcMain.handle("getSettingValue", async (_ev, settingName: string) => {
const setting = Settings[settingName];
if (!setting) {
throw new Error(`Unknown setting: ${settingName}`);
}
const value = await setting.read();
console.debug(`Reading setting value for: ${settingName} = ${value}`);
return value;
});
+57
View File
@@ -0,0 +1,57 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import path from "node:path";
import { spawn } from "node:child_process";
import { app } from "electron";
export function getSquirrelExecutable(): string {
return path.resolve(path.dirname(process.execPath), "..", "Update.exe");
}
function runUpdateExe(args: string[]): Promise<void> {
// Invokes Squirrel's Update.exe which will do things for us like create shortcuts
// Note that there's an Update.exe in the app-x.x.x directory and one in the parent
// directory: we need to run the one in the parent directory, because it discovers
// information about the app by inspecting the directory it's run from.
const updateExe = getSquirrelExecutable();
console.log(`Spawning '${updateExe}' with args '${args}'`);
return new Promise((resolve) => {
spawn(updateExe, args, {
detached: true,
}).on("close", resolve);
});
}
function checkSquirrelHooks(): boolean {
if (process.platform !== "win32") return false;
const cmd = process.argv[1];
const target = path.basename(process.execPath);
switch (cmd) {
case "--squirrel-install":
void runUpdateExe(["--createShortcut=" + target]).then(() => app.quit());
return true;
case "--squirrel-updated":
case "--squirrel-obsolete":
app.quit();
return true;
case "--squirrel-uninstall":
void runUpdateExe(["--removeShortcut=" + target]).then(() => app.quit());
return true;
default:
return false;
}
}
if (checkSquirrelHooks()) {
process.exit(1);
}
+485
View File
@@ -0,0 +1,485 @@
/*
Copyright 2022-2025 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import ElectronStore from "electron-store";
import { app, safeStorage, dialog, type SafeStorage, type Session } from "electron";
import { _t } from "./language-helper.js";
/**
* String union type representing all the safeStorage backends.
* + The "unknown" backend shouldn't exist in practice once the app is ready
* + The "plaintext" is the temporarily-unencrypted backend for migration, data is wholly unencrypted - uses PlaintextStorageWriter
* + The "basic_text" backend is the 'plaintext' backend on Linux, data is encrypted but not using the keychain
* + The "system" backend is the encrypted backend on Windows & macOS, data is encrypted using system keychain
* + All other backends are linux-specific and are encrypted using the keychain
*/
type SafeStorageBackend = ReturnType<SafeStorage["getSelectedStorageBackend"]> | "system" | "plaintext";
/**
* The "unknown" backend is not a valid backend, so we exclude it from the type.
*/
type SaneSafeStorageBackend = Exclude<SafeStorageBackend, "unknown">;
/**
* Map of safeStorage backends to their command line arguments.
* kwallet6 cannot be specified via command line
* https://www.electronjs.org/docs/latest/api/safe-storage#safestoragegetselectedstoragebackend-linux
*/
const safeStorageBackendMap: Omit<Record<SaneSafeStorageBackend, string>, "system" | "plaintext"> = {
basic_text: "basic",
gnome_libsecret: "gnome-libsecret",
kwallet: "kwallet",
kwallet5: "kwallet5",
kwallet6: "kwallet6",
};
function relaunchApp(): void {
console.info("Relaunching app...");
app.relaunch();
app.exit();
}
/**
* Clear all data and relaunch the app.
*/
export async function clearDataAndRelaunch(electronSession: Session): Promise<void> {
Store.instance?.clear();
electronSession.flushStorageData();
await electronSession.clearStorageData();
relaunchApp();
}
interface StoreData {
warnBeforeExit: boolean;
minimizeToTray: boolean;
spellCheckerEnabled: boolean;
autoHideMenuBar: boolean;
locale?: string | string[];
disableHardwareAcceleration: boolean;
enableContentProtection: boolean;
safeStorage?: Record<string, string>;
/** the safeStorage backend used for the safeStorage data as written */
safeStorageBackend?: SafeStorageBackend;
/** whether to explicitly override the safeStorage backend, used for migration */
safeStorageBackendOverride?: boolean;
/** whether to perform a migration of the safeStorage data */
safeStorageBackendMigrate?: boolean;
/** whether to open the app at login minimised, only valid when app.openAtLogin is true */
openAtLoginMinimised: boolean;
}
/**
* Fallback storage writer for secrets, mainly used for automated tests and systems without any safeStorage support.
*/
class StorageWriter {
public constructor(protected readonly store: ElectronStore<StoreData>) {}
public getKey(key: string): `safeStorage.${string}` {
return `safeStorage.${key.replaceAll(".", "-")}`;
}
public set(key: string, secret: string): void {
this.store.set(this.getKey(key), secret);
}
public get(key: string): string | undefined {
return this.store.get(this.getKey(key));
}
public delete(key: string): void {
this.store.delete(this.getKey(key));
}
}
/**
* Storage writer for secrets using safeStorage.
*/
class SafeStorageWriter extends StorageWriter {
public set(key: string, secret: string): void {
this.store.set(this.getKey(key), safeStorage.encryptString(secret).toString("base64"));
}
public get(key: string): string | undefined {
const ciphertext = this.store.get<string, string | undefined>(this.getKey(key));
if (ciphertext) {
try {
return safeStorage.decryptString(Buffer.from(ciphertext, "base64"));
} catch (e) {
console.error("Failed to decrypt secret", e);
console.error("...ciphertext:", JSON.stringify(ciphertext));
}
}
return undefined;
}
}
const enum Mode {
Encrypted = "encrypted", // default
AllowPlaintext = "allow-plaintext",
ForcePlaintext = "force-plaintext",
}
/**
* JSON-backed store for settings which need to be accessible by the main process.
* Secrets are stored within the `safeStorage` object, encrypted with safeStorage.
* Any secrets operations are blocked on Electron app ready emit.
*/
class Store extends ElectronStore<StoreData> {
private static internalInstance?: Store;
public static get instance(): Store | undefined {
return Store.internalInstance;
}
/**
* Prepare the store, does not prepare safeStorage, which needs to be done after the app is ready.
* Must be executed in the first tick of the event loop so that it can call Electron APIs before ready state.
*/
public static initialize(mode: Mode | undefined): Store {
if (Store.internalInstance) {
throw new Error("Store already initialized");
}
const store = new Store(mode ?? Mode.Encrypted);
Store.internalInstance = store;
if (
process.platform === "linux" &&
(store.get("safeStorageBackendOverride") || store.get("safeStorageBackendMigrate"))
) {
const backend = store.get("safeStorageBackend")!;
if (backend in safeStorageBackendMap) {
// If the safeStorage backend which was used to write the data is one we can specify via the commandLine
// then do so to ensure we use the same backend for reading the data.
app.commandLine.appendSwitch(
"password-store",
safeStorageBackendMap[backend as keyof typeof safeStorageBackendMap],
);
}
}
return store;
}
// Provides "raw" access to the underlying secrets storage,
// should be avoided in favour of the getSecret/setSecret/deleteSecret methods.
private secrets?: StorageWriter;
private constructor(private mode: Mode) {
super({
name: "electron-config",
clearInvalidConfig: false,
schema: {
warnBeforeExit: {
type: "boolean",
default: true,
},
minimizeToTray: {
type: "boolean",
default: true,
},
spellCheckerEnabled: {
type: "boolean",
default: true,
},
autoHideMenuBar: {
type: "boolean",
default: true,
},
locale: {
anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
},
disableHardwareAcceleration: {
type: "boolean",
default: false,
},
enableContentProtection: {
type: "boolean",
default: false,
},
safeStorage: {
type: "object",
},
safeStorageBackend: {
type: "string",
},
safeStorageBackendOverride: {
type: "boolean",
},
safeStorageBackendMigrate: {
type: "boolean",
},
openAtLoginMinimised: {
type: "boolean",
default: true,
},
},
});
}
private safeStorageReadyPromise?: Promise<boolean>;
public async safeStorageReady(): Promise<void> {
if (!this.safeStorageReadyPromise) {
throw new Error("prepareSafeStorage must be called before using storage methods");
}
await this.safeStorageReadyPromise;
}
/**
* Normalise the backend to a sane value (exclude `unknown`), respect forcePlaintext mode,
* and ensure that if an encrypted backend is picked that encryption is available, falling back to plaintext if not.
* @param forcePlaintext - whether to force plaintext mode
* @private
*/
private chooseBackend(forcePlaintext: boolean): SaneSafeStorageBackend {
if (forcePlaintext) {
return "plaintext";
}
if (process.platform === "linux") {
// The following enables plain text encryption if the backend used is basic_text.
// It has no significance for any other backend.
// We do this early so that in case we end up using the basic_text backend (either because that's the only one available
// or as a fallback when the configured backend lacks encryption support), encryption is already turned on.
safeStorage.setUsePlainTextEncryption(true);
// Linux safeStorage support is hellish, the support varies on the Desktop Environment used rather than the store itself.
// https://github.com/electron/electron/issues/39789 https://github.com/microsoft/vscode/issues/185212
const selectedBackend = safeStorage.getSelectedStorageBackend();
if (selectedBackend === "unknown" || !safeStorage.isEncryptionAvailable()) {
return "plaintext";
}
return selectedBackend;
}
return safeStorage.isEncryptionAvailable() ? "system" : "plaintext";
}
/**
* Prepare the safeStorage backend for use.
*
* This will relaunch the app in some cases, in which case it will return false and the caller should abort startup.
*
* @param electronSession - The Electron session to use for storage (will be used to clear storage if necessary).
* @returns true if safeStorage was initialised successfully or false if the app will be relaunched
*/
public async prepareSafeStorage(electronSession: Session): Promise<boolean> {
this.safeStorageReadyPromise = this.reallyPrepareSafeStorage(electronSession);
return this.safeStorageReadyPromise;
}
private async reallyPrepareSafeStorage(electronSession: Session): Promise<boolean> {
await app.whenReady();
// The backend the existing data is written with if any
let existingSafeStorageBackend = this.get("safeStorageBackend");
// The backend and encryption status of the currently loaded backend
const backend = this.chooseBackend(this.mode === Mode.ForcePlaintext);
// Handle migrations
if (existingSafeStorageBackend) {
if (existingSafeStorageBackend === "basic_text" && backend !== "plaintext" && backend !== "basic_text") {
this.prepareMigrateBasicTextToPlaintext();
return false;
}
if (this.get("safeStorageBackendMigrate") && backend === "basic_text") {
this.migrateBasicTextToPlaintext();
return false;
}
if (existingSafeStorageBackend === "plaintext" && backend !== "plaintext") {
this.migratePlaintextToEncrypted();
// Ensure we update existingSafeStorageBackend so we don't fall into the "backend changed" clause below
existingSafeStorageBackend = this.get("safeStorageBackend");
}
}
if (!existingSafeStorageBackend) {
// First launch of the app or first launch since the update
if (this.mode === Mode.Encrypted && (backend === "plaintext" || backend === "basic_text")) {
// Ask the user for consent to use a degraded mode
await this.consultUserConsentDegradedMode(backend);
}
// Store the backend used for the safeStorage data so we can detect if it changes, and we know how the data is encoded
this.recordSafeStorageBackend(backend);
} else if (existingSafeStorageBackend !== backend) {
// We already appear to have started using a backend other than the one that we picked, so
// set the override flag and relaunch with the backend we were previously using, unless we
// already have the override flag, in which case we must assume the previous backend is no
// longer usable, in which case we should fall into the next block and warn the user we can't
// migrate.
console.warn(`safeStorage backend changed from ${existingSafeStorageBackend} to ${backend}`);
if (existingSafeStorageBackend in safeStorageBackendMap && !this.get("safeStorageBackendOverride")) {
this.set("safeStorageBackendOverride", true);
relaunchApp();
return false;
} else {
// This will either relaunch the app or throw an execption
await this.consultUserBackendChangedUnableToMigrate(electronSession);
return false;
}
}
console.info(`Using storage mode '${this.mode}' with backend '${backend}'`);
if (backend !== "plaintext") {
this.secrets = new SafeStorageWriter(this);
} else {
this.secrets = new StorageWriter(this);
}
return true;
}
private async consultUserBackendChangedUnableToMigrate(electronSession: Session): Promise<void> {
const { response } = await dialog.showMessageBox({
title: _t("store|error|backend_changed_title"),
message: _t("store|error|backend_changed"),
detail: _t("store|error|backend_changed_detail"),
type: "question",
buttons: [_t("common|no"), _t("common|yes")],
defaultId: 0,
cancelId: 0,
});
if (response === 0) {
throw new Error("safeStorage backend changed and cannot migrate");
}
return clearDataAndRelaunch(electronSession);
}
private async consultUserConsentDegradedMode(backend: "plaintext" | "basic_text"): Promise<void> {
if (backend === "plaintext") {
// Sometimes we may have a working backend that for some reason does not support encryption at the moment.
// This may be because electron reported an incorrect backend or because of some known issues with the keyring itself.
// Or the environment specified `--storage-mode=force-plaintext`.
// In any case, when this happens, we give the user an option to use a weaker form of encryption.
const { response } = await dialog.showMessageBox({
title: _t("store|error|backend_no_encryption_title"),
message: _t("store|error|backend_no_encryption"),
detail: _t("store|error|backend_no_encryption_detail", {
backend: safeStorage.getSelectedStorageBackend(),
brand: global.vectorConfig.brand || "Element",
}),
type: "error",
buttons: [_t("action|cancel"), _t("store|error|unsupported_keyring_use_plaintext")],
defaultId: 0,
cancelId: 0,
});
if (response === 0) {
throw new Error("isEncryptionAvailable=false and user rejected plaintext");
}
} else {
// Electron did not identify a compatible encrypted backend, ask user for consent to degraded mode
const { response } = await dialog.showMessageBox({
title: _t("store|error|unsupported_keyring_title"),
message: _t("store|error|unsupported_keyring"),
detail: _t("store|error|unsupported_keyring_detail", {
brand: global.vectorConfig.brand || "Element",
link: "https://www.electronjs.org/docs/latest/api/safe-storage#safestoragegetselectedstoragebackend-linux",
}),
type: "error",
buttons: [_t("action|cancel"), _t("store|error|unsupported_keyring_use_basic_text")],
defaultId: 0,
cancelId: 0,
});
if (response === 0) {
throw new Error("safeStorage backend basic_text and user rejected it");
}
}
}
private recordSafeStorageBackend(backend: SafeStorageBackend): void {
this.set("safeStorageBackend", backend);
}
/**
* Linux support for upgrading the backend from basic_text to one of the encrypted backends,
* this is quite a tricky process as the backend is not known until the app is ready & cannot be changed once it is.
* 1. We restart the app in safeStorageBackendMigrate mode
* 2. Now that we are in the mode which our data is written in we decrypt the data, write it back in plaintext
* & restart back in default backend mode,
* 3. Finally, we load the plaintext data & encrypt it.
*/
private prepareMigrateBasicTextToPlaintext(): void {
console.info(`Starting safeStorage migration to ${safeStorage.getSelectedStorageBackend()}`);
this.set("safeStorageBackendMigrate", true);
relaunchApp();
}
private migrateBasicTextToPlaintext(): void {
const secrets = new SafeStorageWriter(this);
console.info("Performing safeStorage migration");
const data = this.get("safeStorage");
if (data) {
for (const key in data) {
this.set(secrets.getKey(key), secrets.get(key));
}
this.recordSafeStorageBackend("plaintext");
}
this.delete("safeStorageBackendMigrate");
relaunchApp();
}
private migratePlaintextToEncrypted(): void {
const secrets = new SafeStorageWriter(this);
const selectedSafeStorageBackend = safeStorage.getSelectedStorageBackend();
console.info(`Finishing safeStorage migration to ${selectedSafeStorageBackend}`);
const data = this.get("safeStorage");
if (data) {
for (const key in data) {
secrets.set(key, data[key]);
}
}
this.recordSafeStorageBackend(selectedSafeStorageBackend);
}
/**
* Get the stored secret for the key.
*
* @param key The string key name.
*
* @returns A promise for the secret string.
*/
public async getSecret(key: string): Promise<string | undefined> {
await this.safeStorageReady();
return this.secrets!.get(key);
}
/**
* Add the secret for the key to the keychain.
*
* @param key The string key name.
* @param secret The string password.
*
* @returns A promise for the set password completion.
*/
public async setSecret(key: string, secret: string): Promise<void> {
await this.safeStorageReady();
this.secrets!.set(key, secret);
}
/**
* Delete the stored password for the key.
*
* @param key The string key name.
*/
public async deleteSecret(key: string): Promise<void> {
await this.safeStorageReady();
this.secrets!.delete(key);
}
}
export default Store;
+129
View File
@@ -0,0 +1,129 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2017 Karl Glatz <karl@glatz.biz>
Copyright 2017 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { app, Tray, Menu, nativeImage } from "electron";
import { v5 as uuidv5 } from "uuid";
import { writeFile } from "node:fs/promises";
import pngToIco from "png-to-ico";
import path from "node:path";
import { _t } from "./language-helper.js";
import { getBuildConfig } from "./build-config.js";
import { getBrand } from "./config.js";
import { getIconPath } from "./icon.js";
// This hardcoded uuid is an arbitrary v4 uuid generated on https://www.uuidgenerator.net/version4
const UUID_NAMESPACE = "9fc9c6a0-9ffe-45c9-9cd7-5639ae38b232";
let trayIcon: Tray | null = null;
export function hasTray(): boolean {
return trayIcon !== null;
}
export function destroy(): void {
if (trayIcon) {
trayIcon.destroy();
trayIcon = null;
}
}
function toggleWin(): void {
if (global.mainWindow?.isVisible() && !global.mainWindow.isMinimized() && global.mainWindow.isFocused()) {
global.mainWindow.hide();
} else {
if (global.mainWindow?.isMinimized()) global.mainWindow.restore();
if (!global.mainWindow?.isVisible()) global.mainWindow?.show();
global.mainWindow?.focus();
}
}
export async function create(): Promise<void> {
// no trays on darwin
if (process.platform === "darwin" || trayIcon) return;
const iconPath = await getIconPath();
const defaultIcon = nativeImage.createFromPath(iconPath);
const buildConfig = getBuildConfig();
if (process.platform === "win32" && app.isPackaged && buildConfig.windowsCertSubjectName) {
// Providing a GUID lets Windows be smarter about maintaining user's tray preferences
// https://github.com/electron/electron/pull/21891
// We generate the GUID in a custom arbitrary namespace and use the subject name & userData path
// to differentiate different app builds on the same system.
const guid = uuidv5(`${buildConfig.windowsCertSubjectName}:${app.getPath("userData")}`, UUID_NAMESPACE);
trayIcon = new Tray(defaultIcon, guid);
} else {
trayIcon = new Tray(defaultIcon);
}
trayIcon.setToolTip(getBrand());
initApplicationMenu();
trayIcon.on("click", toggleWin);
// See also, badge.ts
let lastFavicon: string | null = null;
global.mainWindow?.webContents.on("page-favicon-updated", async function (ev, favicons) {
if (!favicons || favicons.length <= 0 || !favicons[0].startsWith("data:")) {
if (lastFavicon !== null) {
global.mainWindow?.setIcon(defaultIcon);
trayIcon?.setImage(defaultIcon);
lastFavicon = null;
}
return;
}
// No need to change, shortcut
if (favicons[0] === lastFavicon) return;
lastFavicon = favicons[0];
let newFavicon = nativeImage.createFromDataURL(favicons[0]);
// Windows likes ico's too much.
if (process.platform === "win32") {
try {
const icoPath = path.join(app.getPath("temp"), "win32_element_icon.ico");
await writeFile(icoPath, await pngToIco(newFavicon.toPNG()));
newFavicon = nativeImage.createFromPath(icoPath);
} catch (e) {
console.error("Failed to make win32 ico", e);
}
// Always update the tray icon for Windows.
trayIcon?.setImage(newFavicon);
} else {
trayIcon?.setImage(newFavicon);
global.mainWindow?.setIcon(newFavicon);
}
});
global.mainWindow?.webContents.on("page-title-updated", function (ev, title) {
trayIcon?.setToolTip(title);
});
}
export function initApplicationMenu(): void {
if (!trayIcon) {
return;
}
const contextMenu = Menu.buildFromTemplate([
{
label: _t("action|show_hide"),
click: toggleWin,
},
{ type: "separator" },
{
label: _t("action|quit"),
click: function (): void {
app.quit();
},
},
]);
trayIcon.setContextMenu(contextMenu);
}
+211
View File
@@ -0,0 +1,211 @@
/*
Copyright 2016-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { app, autoUpdater, ipcMain } from "electron";
import fs from "node:fs/promises";
import os from "node:os";
import { getSquirrelExecutable } from "./squirrelhooks.js";
import { _t } from "./language-helper.js";
import { initialisePromise } from "./ipc.js";
import { getBrand } from "./config.js";
const UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000;
const INITIAL_UPDATE_DELAY_MS = 30 * 1000;
function installUpdate(): void {
// for some reason, quitAndInstall does not fire the
// before-quit event, so we need to set the flag here.
global.appQuitting = true;
autoUpdater.quitAndInstall();
}
// Workaround for Squirrel.Mac wedging auto-restart if latest check for update failed
// From https://github.com/vector-im/element-web/issues/12433#issuecomment-1508995119
async function safeCheckForUpdate(): Promise<void> {
if (process.platform === "darwin") {
const feedUrl = autoUpdater.getFeedURL();
// On Mac if the user has already downloaded an update but not installed it and
// we check again and no additional new update is available the app ends up in a
// bad state and doesn't restart after installing any updates that are downloaded.
// To avoid this we check manually whether an update is available and call the
// autoUpdater.checkForUpdates() when something new is there.
try {
const res = await fetch(feedUrl);
const { currentRelease } = (await res.json()) as { currentRelease: string };
const latestVersionDownloaded = latestUpdateDownloaded?.releaseName;
console.info(
`Latest version from release download: ${currentRelease} (current: ${app.getVersion()}, most recent downloaded ${latestVersionDownloaded}})`,
);
if (currentRelease === app.getVersion() || currentRelease === latestVersionDownloaded) {
ipcChannelSendUpdateStatus(false);
return;
}
} catch (err) {
console.error(`Error checking for updates ${feedUrl}`, err);
ipcChannelSendUpdateStatus(false);
return;
}
}
autoUpdater.checkForUpdates();
}
async function pollForUpdates(): Promise<void> {
try {
// If we've already got a new update downloaded, then stop trying to check for new ones, as according to the doc
// at https://github.com/electron/electron/blob/main/docs/api/auto-updater.md#autoupdatercheckforupdates
// we'll just keep re-downloading the same update.
// As a hunch, this might also be causing https://github.com/vector-im/element-web/issues/12433
// due to the update checks colliding with the pending install somehow
if (!latestUpdateDownloaded) {
await safeCheckForUpdate();
} else {
console.log("Skipping update check as download already present");
global.mainWindow?.webContents.send("update-downloaded", latestUpdateDownloaded);
}
} catch (e) {
console.log("Couldn't check for update", e);
}
}
export async function start(updateBaseUrl: string): Promise<void> {
if (!(await available())) return;
console.log(`Starting auto update with base URL: ${updateBaseUrl}`);
if (!updateBaseUrl.endsWith("/")) {
updateBaseUrl = updateBaseUrl + "/";
}
try {
let url: string;
let serverType: "json" | undefined;
if (process.platform === "darwin") {
// On macOS it takes a JSON file with a map between versions and their URLs
url = `${updateBaseUrl}macos/releases.json`;
serverType = "json";
} else if (process.platform === "win32") {
// On windows it takes a base path and looks for files under that path.
url = `${updateBaseUrl}win32/${process.arch}/`;
} else {
// Squirrel / electron only supports auto-update on these two platforms.
// I'm not even going to try to guess which feed style they'd use if they
// implemented it on Linux, or if it would be different again.
return;
}
if (url) {
console.log(`Update URL: ${url}`);
autoUpdater.setFeedURL({ url, serverType });
// We check for updates ourselves rather than using 'updater' because we need to
// do it in the main process (and we don't really need to check every 10 minutes:
// every hour should be just fine for a desktop app)
// However, we still let the main window listen for the update events.
// We also wait a short time before checking for updates the first time because
// of squirrel on windows and it taking a small amount of time to release a
// lock file.
setTimeout(pollForUpdates, INITIAL_UPDATE_DELAY_MS);
setInterval(pollForUpdates, UPDATE_POLL_INTERVAL_MS);
}
} catch (err) {
// will fail if running in debug mode
console.log("Couldn't enable update checking", err);
}
}
/**
* Check if auto update is available on this platform.
* Has a side effect of firing showToast on EOL platforms so must only be called once!
* @returns True if auto update is available
*/
async function available(): Promise<boolean> {
if (process.platform === "linux") {
// Auto update is not supported on Linux
console.warn("Auto update not supported on this platform");
return false;
}
if (process.platform === "win32") {
try {
await fs.access(getSquirrelExecutable());
} catch {
console.warn("Squirrel not found, auto update not supported");
return false;
}
}
// Otherwise we're either on macOS or Windows with Squirrel
if (process.platform === "darwin") {
// OS release returns the Darwin kernel version, not the macOS version, see
// https://en.wikipedia.org/wiki/Darwin_(operating_system)#Release_history to interpret it
const release = os.release();
const major = parseInt(release.split(".")[0], 10);
if (major < 21) {
// If the macOS version is too old for modern Electron support then disable auto update to prevent the app updating and bricking itself.
// The oldest macOS version supported by Chromium/Electron 38 is Monterey (12.x) which started with Darwin 21.0
initialisePromise.then(() => {
ipcMain.emit("showToast", {
title: _t("eol|title"),
description: _t("eol|no_more_updates", { brand: getBrand() }),
});
});
console.warn("Auto update not supported, macOS version too old");
return false;
} else if (major < 22) {
// If the macOS version is EOL then show a warning message.
// The oldest macOS version still supported by Apple is Ventura (13.x) which started with Darwin 22.0
initialisePromise.then(() => {
ipcMain.emit("showToast", {
title: _t("eol|title"),
description: _t("eol|warning", { brand: getBrand() }),
});
});
}
}
return true;
}
ipcMain.on("install_update", installUpdate);
ipcMain.on("check_updates", pollForUpdates);
function ipcChannelSendUpdateStatus(status: boolean | string): void {
global.mainWindow?.webContents.send("check_updates", status);
}
interface ICachedUpdate {
releaseNotes: string;
releaseName: string;
releaseDate: Date;
updateURL: string;
}
// cache the latest update which has been downloaded as electron offers no api to read it
let latestUpdateDownloaded: ICachedUpdate | undefined;
autoUpdater
.on("update-available", function () {
ipcChannelSendUpdateStatus(true);
})
.on("update-not-available", function () {
if (latestUpdateDownloaded) {
// the only time we will get `update-not-available` if `latestUpdateDownloaded` is already set
// is if the user used the Manual Update check and there is no update newer than the one we
// have downloaded, so show it to them as the latest again.
global.mainWindow?.webContents.send("update-downloaded", latestUpdateDownloaded);
} else {
ipcChannelSendUpdateStatus(false);
}
})
.on("error", function (error) {
ipcChannelSendUpdateStatus(error.message);
});
autoUpdater.on("update-downloaded", (ev, releaseNotes, releaseName, releaseDate, updateURL) => {
// forward to renderer
latestUpdateDownloaded = { releaseNotes, releaseName, releaseDate, updateURL };
global.mainWindow?.webContents.send("update-downloaded", latestUpdateDownloaded);
});
+70
View File
@@ -0,0 +1,70 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import afs from "node:fs/promises";
export async function randomArray(size: number): Promise<string> {
return new Promise((resolve, reject) => {
crypto.randomBytes(size, (err, buf) => {
if (err) {
reject(err);
} else {
resolve(buf.toString("base64").replace(/=+$/g, ""));
}
});
});
}
type JsonValue = null | string | number;
type JsonArray = Array<JsonValue | JsonObject | JsonArray>;
export interface JsonObject {
[key: string]: JsonObject | JsonArray | JsonValue;
}
export type Json = JsonArray | JsonObject;
/**
* Synchronously load a JSON file from the local filesystem.
* Unlike `require`, will never execute any javascript in a loaded file.
* @param paths - An array of path segments which will be joined using the system's path delimiter.
*/
export function loadJsonFile<T extends Json>(...paths: string[]): T {
const joinedPaths = path.join(...paths);
if (!fs.existsSync(joinedPaths)) {
console.log(`Skipping nonexistent file: ${joinedPaths}`);
return {} as T;
}
const file = fs.readFileSync(joinedPaths, { encoding: "utf-8" });
return JSON.parse(file);
}
/**
* Looks for a given path relative to root
* @param name - dir name to use in logging
* @param root - the root to search from
* @param rawPaths - the paths to search, in order
*/
export async function tryPaths(name: string, root: string, rawPaths: string[]): Promise<string> {
// Make everything relative to root
const paths = rawPaths.map((p) => path.join(root, p));
for (const p of paths) {
try {
await afs.stat(p);
return p + "/";
} catch {}
}
console.log(`Couldn't find ${name} files in any of: `);
for (const p of paths) {
console.log("\t" + path.resolve(p));
}
throw new Error(`Failed to find ${name} files`);
}
+250
View File
@@ -0,0 +1,250 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { app, shell, Menu, type MenuItem, type MenuItemConstructorOptions } from "electron";
import { _t } from "./language-helper.js";
const isMac = process.platform === "darwin";
export function buildMenuTemplate(): Menu {
// Menu template from http://electron.atom.io/docs/api/menu/, edited
const template: Array<MenuItemConstructorOptions | MenuItem> = [
{
label: _t("action|edit"),
accelerator: "e",
submenu: [
{
role: "undo",
label: _t("action|undo"),
},
{
role: "redo",
label: _t("action|redo"),
},
{ type: "separator" },
{
role: "cut",
label: _t("action|cut"),
},
{
role: "copy",
label: _t("action|copy"),
},
{
role: "paste",
label: _t("action|paste"),
},
{
role: "pasteAndMatchStyle",
label: _t("action|paste_match_style"),
},
{
role: "delete",
label: _t("action|delete"),
},
{
role: "selectAll",
label: _t("action|select_all"),
},
],
},
{
label: _t("view_menu|view"),
accelerator: "V",
submenu: [
{ type: "separator" },
{
role: "resetZoom",
accelerator: "CmdOrCtrl+Num0",
visible: false,
},
{
role: "zoomIn",
accelerator: "CmdOrCtrl+NumAdd",
visible: false,
},
{
role: "zoomOut",
accelerator: "CmdOrCtrl+NumSub",
visible: false,
},
{
role: "resetZoom",
label: _t("view_menu|actual_size"),
},
{
role: "zoomIn",
label: _t("action|zoom_in"),
},
{
role: "zoomOut",
label: _t("action|zoom_out"),
},
{ type: "separator" },
// in macOS the Preferences menu item goes in the first menu
...(!isMac
? [
{
label: _t("common|preferences"),
click(): void {
global.mainWindow?.webContents.send("preferences");
},
},
]
: []),
{
role: "togglefullscreen",
label: _t("view_menu|toggle_full_screen"),
},
{
role: "toggleDevTools",
label: _t("view_menu|toggle_developer_tools"),
},
],
},
{
label: _t("window_menu|label"),
accelerator: "w",
role: "window",
submenu: [
{
role: "minimize",
label: _t("action|minimise"),
},
{
role: "close",
label: _t("action|close"),
},
],
},
{
label: _t("common|help"),
accelerator: "h",
role: "help",
submenu: [
{
// XXX: vectorConfig won't have defaults applied to it so we need to duplicate them here
label: _t("common|brand_help", { brand: global.vectorConfig?.brand || "Element" }),
click(): void {
void shell.openExternal(global.vectorConfig?.help_url || "https://element.io/help");
},
},
],
},
];
// macOS has specific menu conventions...
if (isMac) {
template.unshift({
// first macOS menu is the name of the app
role: "appMenu",
label: app.name,
submenu: [
{
role: "about",
label: _t("common|about") + " " + app.name,
},
{ type: "separator" },
{
label: _t("common|preferences") + "…",
accelerator: "Command+,", // Mac-only accelerator
click(): void {
global.mainWindow?.webContents.send("preferences");
},
},
{ type: "separator" },
{
role: "services",
label: _t("menu|services"),
submenu: [],
},
{ type: "separator" },
{
role: "hide",
label: _t("menu|hide"),
},
{
role: "hideOthers",
label: _t("menu|hide_others"),
},
{
role: "unhide",
label: _t("menu|unhide"),
},
{ type: "separator" },
{
role: "quit",
label: _t("action|quit"),
},
],
});
// Edit menu.
// This has a 'speech' section on macOS
(template[1].submenu as MenuItemConstructorOptions[]).push(
{ type: "separator" },
{
label: _t("edit_menu|speech"),
submenu: [
{
role: "startSpeaking",
label: _t("edit_menu|speech_start_speaking"),
},
{
role: "stopSpeaking",
label: _t("edit_menu|speech_stop_speaking"),
},
],
},
);
// Window menu.
// This also has specific functionality on macOS
template[3].submenu = [
{
label: _t("action|close"),
accelerator: "CmdOrCtrl+W",
role: "close",
},
{
label: _t("action|minimise"),
accelerator: "CmdOrCtrl+M",
role: "minimize",
},
{
label: _t("window_menu|zoom"),
role: "zoom",
},
{
type: "separator",
},
{
label: _t("window_menu|bring_all_to_front"),
role: "front",
},
];
} else {
template.unshift({
label: _t("file_menu|label"),
accelerator: "f",
submenu: [
// For some reason, 'about' does not seem to work on windows.
/*{
role: 'about',
label: _t('About'),
},*/
{
role: "quit",
label: _t("action|quit"),
},
],
});
}
return Menu.buildFromTemplate(template);
}
+310
View File
@@ -0,0 +1,310 @@
/*
Copyright 2021-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
clipboard,
nativeImage,
Menu,
MenuItem,
shell,
dialog,
ipcMain,
type NativeImage,
type WebContents,
type ContextMenuParams,
type DownloadItem,
type MenuItemConstructorOptions,
type IpcMainEvent,
type Event,
} from "electron";
import url from "node:url";
import fs from "node:fs";
import { pipeline } from "node:stream/promises";
import path from "node:path";
import { _t } from "./language-helper.js";
const MAILTO_PREFIX = "mailto:";
const PERMITTED_URL_SCHEMES: string[] = ["http:", "https:", MAILTO_PREFIX];
function safeOpenURL(target: string): void {
// openExternal passes the target to open/start/xdg-open,
// so put fairly stringent limits on what can be opened
// (for instance, open /bin/sh does indeed open a terminal
// with a shell, albeit with no arguments)
const parsedUrl = url.parse(target);
if (PERMITTED_URL_SCHEMES.includes(parsedUrl.protocol!)) {
// explicitly use the URL re-assembled by the url library,
// so we know the url parser has understood all the parts
// of the input string
const newTarget = url.format(parsedUrl);
void shell.openExternal(newTarget);
}
}
function onWindowOrNavigate(ev: Event, target: string): void {
// always prevent the default: if something goes wrong,
// we don't want to end up opening it in the electron
// app, as we could end up opening any sort of random
// url in a window that has node scripting access.
ev.preventDefault();
safeOpenURL(target);
}
function writeNativeImage(filePath: string, img: NativeImage): Promise<void> {
switch (filePath.split(".").pop()?.toLowerCase()) {
case "jpg":
case "jpeg":
return fs.promises.writeFile(filePath, img.toJPEG(100));
case "bmp":
return fs.promises.writeFile(filePath, img.toBitmap());
case "png":
default:
return fs.promises.writeFile(filePath, img.toPNG());
}
}
function onLinkContextMenu(ev: Event, params: ContextMenuParams, webContents: WebContents): void {
let url = params.linkURL || params.srcURL;
if (url.startsWith("vector://vector/webapp")) {
// Avoid showing a context menu for app icons
if (params.hasImageContents) return;
const baseUrl = vectorConfig.web_base_url ?? "https://app.element.io/";
// Rewrite URL so that it can be used outside the app
url = baseUrl + url.substring(23);
}
const popupMenu = new Menu();
// No point trying to open blob: URLs in an external browser: it ain't gonna work.
if (!url.startsWith("blob:")) {
popupMenu.append(
new MenuItem({
label: url,
click(): void {
safeOpenURL(url);
},
}),
);
}
if (params.hasImageContents) {
popupMenu.append(
new MenuItem({
label: _t("right_click_menu|copy_image"),
accelerator: "c",
click(): void {
webContents.copyImageAt(params.x, params.y);
},
}),
);
}
// No point offering to copy a blob: URL either
if (!url.startsWith("blob:")) {
// Special-case e-mail URLs to strip the `mailto:` like modern browsers do
if (url.startsWith(MAILTO_PREFIX)) {
popupMenu.append(
new MenuItem({
label: _t("right_click_menu|copy_email"),
accelerator: "a",
click(): void {
clipboard.writeText(url.substr(MAILTO_PREFIX.length));
},
}),
);
} else {
popupMenu.append(
new MenuItem({
label: params.hasImageContents
? _t("right_click_menu|copy_image_url")
: _t("right_click_menu|copy_link_url"),
accelerator: "a",
click(): void {
clipboard.writeText(url);
},
}),
);
}
}
// XXX: We cannot easily save a blob from the main process as
// only the renderer can resolve them so don't give the user an option to.
if (params.hasImageContents && !url.startsWith("blob:")) {
popupMenu.append(
new MenuItem({
label: _t("right_click_menu|save_image_as"),
accelerator: "s",
async click(): Promise<void> {
const targetFileName = params.suggestedFilename || params.altText || "image.png";
const { filePath } = await dialog.showSaveDialog({
defaultPath: targetFileName,
});
if (!filePath) return; // user cancelled dialog
try {
if (url.startsWith("data:")) {
await writeNativeImage(filePath, nativeImage.createFromDataURL(url));
} else {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`unexpected response ${resp.statusText}`);
if (!resp.body) throw new Error(`unexpected response has no body ${resp.statusText}`);
await pipeline(resp.body, fs.createWriteStream(filePath));
}
} catch (err) {
console.error(err);
void dialog.showMessageBox({
type: "error",
title: _t("right_click_menu|save_image_as_error_title"),
message: _t("right_click_menu|save_image_as_error_description"),
});
}
},
}),
);
}
// popup() requires an options object even for no options
popupMenu.popup({});
ev.preventDefault();
}
function cutCopyPasteSelectContextMenus(
params: ContextMenuParams,
webContents: WebContents,
): MenuItemConstructorOptions[] {
const options: MenuItemConstructorOptions[] = [];
if (params.misspelledWord) {
params.dictionarySuggestions.forEach((word) => {
options.push({
label: word,
click: () => {
webContents.replaceMisspelling(word);
},
});
});
options.push(
{
type: "separator",
},
{
label: _t("right_click_menu|add_to_dictionary"),
click: () => {
webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord);
},
},
{
type: "separator",
},
);
}
options.push(
{
role: "cut",
label: _t("action|cut"),
accelerator: "t",
enabled: params.editFlags.canCut,
},
{
role: "copy",
label: _t("action|copy"),
accelerator: "c",
enabled: params.editFlags.canCopy,
},
{
role: "paste",
label: _t("action|paste"),
accelerator: "p",
enabled: params.editFlags.canPaste,
},
{
role: "pasteAndMatchStyle",
enabled: params.editFlags.canPaste,
},
{
role: "selectAll",
label: _t("action|select_all"),
accelerator: "a",
enabled: params.editFlags.canSelectAll,
},
);
return options;
}
function onSelectedContextMenu(ev: Event, params: ContextMenuParams, webContents: WebContents): void {
const items = cutCopyPasteSelectContextMenus(params, webContents);
const popupMenu = Menu.buildFromTemplate(items);
// popup() requires an options object even for no options
popupMenu.popup({});
ev.preventDefault();
}
function onEditableContextMenu(ev: Event, params: ContextMenuParams, webContents: WebContents): void {
const items: MenuItemConstructorOptions[] = [
{ role: "undo" },
{ role: "redo", enabled: params.editFlags.canRedo },
{ type: "separator" },
...cutCopyPasteSelectContextMenus(params, webContents),
];
const popupMenu = Menu.buildFromTemplate(items);
// popup() requires an options object even for no options
popupMenu.popup({});
ev.preventDefault();
}
let userDownloadIndex = 0;
const userDownloadMap = new Map<number, string>(); // Map from id to path
ipcMain.on("userDownloadAction", function (ev: IpcMainEvent, { id, open = false }) {
const path = userDownloadMap.get(id);
if (open && path) {
void shell.openPath(path);
}
userDownloadMap.delete(id);
});
export default (webContents: WebContents): void => {
webContents.setWindowOpenHandler((details) => {
safeOpenURL(details.url);
return { action: "deny" };
});
webContents.on("will-navigate", (ev: Event, target: string): void => {
if (target.startsWith("vector://")) return;
return onWindowOrNavigate(ev, target);
});
webContents.on("context-menu", function (ev: Event, params: ContextMenuParams): void {
if (params.linkURL || params.srcURL) {
onLinkContextMenu(ev, params, webContents);
} else if (params.selectionText) {
onSelectedContextMenu(ev, params, webContents);
} else if (params.isEditable) {
onEditableContextMenu(ev, params, webContents);
}
});
webContents.session.on("will-download", (event: Event, item: DownloadItem): void => {
item.once("done", (event, state) => {
if (state === "completed") {
const savePath = item.getSavePath();
const id = userDownloadIndex++;
userDownloadMap.set(id, savePath);
webContents.send("userDownloadCompleted", {
id,
name: path.basename(savePath),
});
}
});
});
};