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
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:
Vendored
+22
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "Ζουμ"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "بزرگنمایی"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 l’adresse e-mail",
|
||||
"copy_image": "Copier l’image",
|
||||
"copy_image_url": "Copier l'adresse de l'image",
|
||||
"copy_link_url": "Copier l’adresse du lien",
|
||||
"save_image_as": "Enregistrer l’image sous…",
|
||||
"save_image_as_error_description": "L’image n’a pas pu être enregistrée",
|
||||
"save_image_as_error_title": "Échec de la sauvegarde de l’image"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "גודל תצוגה"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "Մեծացնել"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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ð"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "ズーム"
|
||||
}
|
||||
}
|
||||
@@ -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": "გაზუსტება"
|
||||
}
|
||||
}
|
||||
@@ -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,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": "ຊູມ"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "Масштаб"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "Масштаб"
|
||||
}
|
||||
}
|
||||
@@ -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": "縮放"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user